diff --git a/.gitignore b/.gitignore
index 87d6491..5dbfa17 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,4 +27,6 @@ patch_*.sh
repair_*.sh
inspect_*.sh
remove_*.sh
+#logs
+examples/NMFS/*/outputs/*.log
# Editors
diff --git a/a.out b/a.out
new file mode 100755
index 0000000..a3fc030
Binary files /dev/null and b/a.out differ
diff --git a/a.out.dSYM/Contents/Info.plist b/a.out.dSYM/Contents/Info.plist
new file mode 100644
index 0000000..3679a65
--- /dev/null
+++ b/a.out.dSYM/Contents/Info.plist
@@ -0,0 +1,20 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ English
+ CFBundleIdentifier
+ com.apple.xcode.dsym.a.out
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundlePackageType
+ dSYM
+ CFBundleSignature
+ ????
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+
+
diff --git a/a.out.dSYM/Contents/Resources/DWARF/a.out b/a.out.dSYM/Contents/Resources/DWARF/a.out
new file mode 100644
index 0000000..3d967bd
Binary files /dev/null and b/a.out.dSYM/Contents/Resources/DWARF/a.out differ
diff --git a/a.out.dSYM/Contents/Resources/Relocations/aarch64/a.out.yml b/a.out.dSYM/Contents/Resources/Relocations/aarch64/a.out.yml
new file mode 100644
index 0000000..9c81f13
--- /dev/null
+++ b/a.out.dSYM/Contents/Resources/Relocations/aarch64/a.out.yml
@@ -0,0 +1,5 @@
+---
+triple: 'arm64-apple-darwin'
+binary-path: a.out
+relocations: []
+...
diff --git a/add_laplace_result_component_fields.sh b/add_laplace_result_component_fields.sh
new file mode 100755
index 0000000..8cbcbae
--- /dev/null
+++ b/add_laplace_result_component_fields.sh
@@ -0,0 +1,63 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+FILE="core/laplace.hpp"
+
+if [[ ! -f "$FILE" ]]; then
+ echo "ERROR: $FILE not found. Run this from the Quadra repo root."
+ exit 1
+fi
+
+STAMP="$(date +%Y%m%d_%H%M%S)"
+BACKUP="${FILE}.before_laplace_result_component_fields.${STAMP}"
+cp "$FILE" "$BACKUP"
+echo "Backed up $FILE to:"
+echo " $BACKUP"
+
+python3 - <<'PY'
+from pathlib import Path
+import re
+
+path = Path("core/laplace.hpp")
+text = path.read_text()
+
+if "joint_objective" in text and "laplace_logdet" in text and "laplace_constant" in text:
+ print("LaplaceResult component fields already appear to exist. No patch needed.")
+ raise SystemExit(0)
+
+m = re.search(
+ r'(template\s*<\s*typename\s+Model\s*>\s*\n\s*struct\s+LaplaceResult\s*\{)',
+ text
+)
+if not m:
+ m = re.search(r'(struct\s+LaplaceResult\s*\{)', text)
+
+if not m:
+ raise RuntimeError("Could not find struct LaplaceResult in core/laplace.hpp")
+
+insert_at = m.end()
+
+fields = """\n
+ // Component breakdown of the Laplace objective:
+ //
+ // value = joint_objective + 0.5 * laplace_logdet - laplace_constant
+ //
+ // These are intentionally stored for diagnostics/reporting and for
+ // optimizer-side bookkeeping. They do not change the objective math.
+ double joint_objective = 0.0;
+ double laplace_logdet = 0.0;
+ double laplace_constant = 0.0;
+"""
+
+text = text[:insert_at] + fields + text[insert_at:]
+path.write_text(text)
+print("Inserted joint_objective, laplace_logdet, and laplace_constant into LaplaceResult.")
+PY
+
+echo
+echo "Relevant LaplaceResult region:"
+grep -n "struct LaplaceResult\|joint_objective\|laplace_logdet\|laplace_constant" "$FILE" | head -40
+
+echo
+echo "Done. Rebuild now:"
+echo 'clang++ -std=c++17 -g -I"external/eigen/" examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured.cpp'
diff --git a/add_science_center_validation_roadmap_v1.sh b/add_science_center_validation_roadmap_v1.sh
new file mode 100755
index 0000000..96d15de
--- /dev/null
+++ b/add_science_center_validation_roadmap_v1.sh
@@ -0,0 +1,191 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+echo "== Add science-center validation roadmap and SEFSC red-snapper scaffold =="
+
+mkdir -p docs/validation
+mkdir -p examples/NMFS/sefsc_red_snapper/{data,quadra,tmb,outputs,validation}
+
+cat > docs/validation/science-center-example-roadmap.md <<'MD'
+# Science Center Example Validation Roadmap
+
+This document tracks a proposed validation suite with one representative assessment-style example from each NOAA Fisheries Science Center.
+
+The goal is to build examples that are:
+- public-data-safe or synthetic,
+- reproducible,
+- paired with TMB reference implementations where practical,
+- documented with expected outputs,
+- capable of reporting uncertainty, derived quantities, and projections.
+
+## Proposed example set
+
+| Science Center | Example | Status | Main validation target |
+|---|---|---:|---|
+| PIFSC | Opakapaka projection example | In progress | Projection validation and Level-1 uncertainty reporting |
+| SEFSC | Red-snapper-style age-structured model | Scaffolded | Age structure, selectivity, recruitment deviations, projections |
+| NEFSC | Groundfish/index-heavy assessment | Planned | Multiple indices, survey likelihoods, retrospective-style diagnostics |
+| NWFSC | West Coast age-structured model | Planned | Age composition, selectivity, biological reference points |
+| AFSC | Pollock/sablefish-style model | Planned | Recruitment deviations, state-space/random-effect scalability |
+| SWFSC | CPS/tuna-style model | Planned | Time-varying dynamics, index scaling, projection scenarios |
+
+## Shared validation requirements
+
+Each example should eventually include:
+
+1. Quadra implementation
+2. TMB comparison implementation
+3. synthetic or public-data-safe input data
+4. reproducible runner
+5. fit diagnostics
+6. standard errors and confidence intervals
+7. random-effect conditional uncertainty
+8. derived quantity uncertainty
+9. projection envelopes
+10. comparison summary against TMB
+
+## Recommended directory layout
+
+```text
+examples//
+ README.md
+ data/
+ quadra/
+ tmb/
+ outputs/
+ validation/
+```
+
+## Development order
+
+1. Finish Opakapaka Level-1 uncertainty reporting.
+2. Scaffold SEFSC red-snapper-style age-structured model.
+3. Add minimal Quadra implementation.
+4. Add TMB reference implementation.
+5. Add validation summary and uncertainty outputs.
+6. Repeat for the remaining science centers.
+MD
+
+cat > examples/NMFS/sefsc_red_snapper/README.md <<'MD'
+# SEFSC Red-Snapper-Style Assessment Example
+
+This directory is a placeholder for a synthetic, public-data-safe red-snapper-style assessment example.
+
+The goal is not to reproduce an official assessment. The goal is to provide a representative SEFSC-style validation case for Quadra with age structure, selectivity, recruitment deviations, uncertainty reporting, and projections.
+
+## Planned model features
+
+- age-structured population dynamics
+- catch likelihood
+- survey/index likelihood
+- age-composition likelihood
+- recruitment deviations as random effects
+- age-based selectivity
+- derived quantities:
+ - biomass
+ - spawning biomass proxy
+ - depletion
+ - fishing mortality proxy
+ - MSY-like reference metrics
+- projection scenarios
+- uncertainty outputs:
+ - inverse Hessian / covariance
+ - standard errors
+ - confidence intervals
+ - random-effect conditional uncertainty
+ - derived quantity uncertainty
+ - projection envelopes
+
+## Directory layout
+
+```text
+data/ synthetic or public-data-safe inputs
+quadra/ Quadra implementation
+tmb/ TMB reference implementation
+outputs/ generated outputs, ignored by git
+validation/ comparison summaries and validation notes
+```
+
+## Initial validation target
+
+The first milestone is a minimal working model with:
+
+1. deterministic age-structured dynamics,
+2. one abundance index,
+3. synthetic catch observations,
+4. recruitment deviations,
+5. TMB side-by-side comparison,
+6. Level-1 uncertainty outputs.
+MD
+
+cat > examples/NMFS/sefsc_red_snapper/validation/validation_plan.md <<'MD'
+# SEFSC Red-Snapper-Style Validation Plan
+
+## Level 0: deterministic fit
+
+- Build a minimal deterministic age-structured model.
+- Fit fixed effects only.
+- Confirm objective value and parameter estimates are stable.
+
+## Level 1: random effects and uncertainty
+
+- Add recruitment deviations as random effects.
+- Extract conditional random-effect uncertainty.
+- Add fixed-effect covariance and confidence intervals.
+- Add derived quantity uncertainty.
+
+## Level 2: TMB comparison
+
+- Implement matching TMB reference model.
+- Compare:
+ - objective value
+ - fixed-effect estimates
+ - random-effect modes
+ - standard errors
+ - derived quantities
+ - projection summaries
+
+## Level 3: projections
+
+- Add projection scenarios.
+- Report projection envelopes.
+- Compare Quadra and TMB projection outputs where feasible.
+
+## Notes
+
+This example should remain synthetic or public-data-safe. It should not be presented as an official red snapper assessment.
+MD
+
+cat > examples/NMFS/sefsc_red_snapper/data/README.md <<'MD'
+# Data
+
+Synthetic or public-data-safe input files will live here.
+
+Do not commit generated outputs or confidential assessment data.
+MD
+
+cat > examples/NMFS/sefsc_red_snapper/quadra/README.md <<'MD'
+# Quadra Implementation
+
+Quadra model source files for the SEFSC red-snapper-style example will live here.
+MD
+
+cat > examples/NMFS/sefsc_red_snapper/tmb/README.md <<'MD'
+# TMB Reference Implementation
+
+TMB comparison files for the SEFSC red-snapper-style example will live here.
+MD
+
+cat > examples/NMFS/sefsc_red_snapper/outputs/.gitignore <<'EOF'
+*
+!.gitignore
+EOF
+
+echo
+echo "Created:"
+echo " docs/validation/science-center-example-roadmap.md"
+echo " examples/NMFS/sefsc_red_snapper/"
+echo
+echo "Next:"
+echo " git add docs/validation/science-center-example-roadmap.md examples/NMFS/sefsc_red_snapper"
+echo " git commit -m \"Add science center validation roadmap and SEFSC scaffold\""
diff --git a/add_sefsc_red_snapper_age_comp_likelihood_v1.sh b/add_sefsc_red_snapper_age_comp_likelihood_v1.sh
new file mode 100755
index 0000000..e2c27d1
--- /dev/null
+++ b/add_sefsc_red_snapper_age_comp_likelihood_v1.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+python3 - <<'PY'
+from pathlib import Path
+
+p = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp")
+s = p.read_text()
+
+s = s.replace(
+"""template
+T logistic_selectivity_t""",
+"""template
+T age_comp_nll(const std::array& observed,
+ const std::array& predicted,
+ double effective_n,
+ double floor = 1.0e-12) {
+ T nll = T(0.0);
+ for (int a = 0; a < kAges; ++a) {
+ const auto i = static_cast(a);
+ const double obs = std::max(observed[i], 0.0);
+ if (obs > 0.0) {
+ nll = nll - T(effective_n * obs) * log_t(max_t(predicted[i], floor));
+ }
+ }
+ return nll;
+}
+
+template
+T logistic_selectivity_t""",
+1)
+
+s = s.replace(
+""" const T sigma_log_catch = T(0.15);
+ const double min_positive = 1.0e-12;""",
+""" const T sigma_log_catch = T(0.15);
+ const double age_comp_effective_n = 50.0;
+ const double min_positive = 1.0e-12;""",
+1)
+
+s = s.replace(
+""" if (obs.catch_mt > 0.0) {
+ const T z = (log_t(T(obs.catch_mt)) -
+ log_t(max_t(catch_hat, min_positive))) /
+ sigma_log_catch;
+ nll = nll + T(0.5) * square_t(z);
+ }
+
+ std::array next{};""",
+""" if (obs.catch_mt > 0.0) {
+ const T z = (log_t(T(obs.catch_mt)) -
+ log_t(max_t(catch_hat, min_positive))) /
+ sigma_log_catch;
+ nll = nll + T(0.5) * square_t(z);
+ }
+
+ std::array pred_age_comp{};
+ T selected_numbers_sum = T(0.0);
+ for (int a = 0; a < kAges; ++a) {
+ const auto i = static_cast(a);
+ pred_age_comp[i] = n[i] * selectivity[i];
+ selected_numbers_sum = selected_numbers_sum + pred_age_comp[i];
+ }
+ for (int a = 0; a < kAges; ++a) {
+ const auto i = static_cast(a);
+ pred_age_comp[i] =
+ pred_age_comp[i] / max_t(selected_numbers_sum, min_positive);
+ }
+
+ nll = nll + age_comp_nll(obs.age_comp, pred_age_comp,
+ age_comp_effective_n, min_positive);
+
+ std::array next{};""",
+1)
+
+p.write_text(s)
+PY
+
+cat > examples/NMFS/sefsc_red_snapper/validation/age_composition_likelihood_checklist.md <<'MD'
+# Age-Composition Likelihood Checklist
+
+- [x] predicted selected age composition added
+- [x] multinomial-style negative log likelihood added
+- [x] fixed effective sample size added
+- [ ] selectivity parameters estimated
+- [ ] age-composition residuals written
+- [ ] Dirichlet-multinomial alternative
+MD
diff --git a/add_sefsc_red_snapper_age_structured_v1.sh b/add_sefsc_red_snapper_age_structured_v1.sh
new file mode 100755
index 0000000..51e67e5
--- /dev/null
+++ b/add_sefsc_red_snapper_age_structured_v1.sh
@@ -0,0 +1,310 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+echo "== Add SEFSC red snapper deterministic age-structured scaffold =="
+
+BASE="examples/NMFS/sefsc_red_snapper"
+mkdir -p "$BASE"/{data,quadra,outputs,validation}
+
+cat > "$BASE/quadra/red_snapper_age_structured.cpp" <<'CPP'
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace sefsc_red_snapper {
+
+constexpr int kAges = 10;
+
+struct Observation {
+ int year = 0;
+ double catch_mt = 0.0;
+ double index = 0.0;
+ std::array age_comp{};
+};
+
+struct AgeStructuredParams {
+ double log_r0 = std::log(1200.0);
+ double log_m = std::log(0.18);
+ double log_fbar = std::log(0.22);
+ double log_q = std::log(0.001);
+ double sel_a50 = 4.0;
+ double sel_slope = 1.2;
+};
+
+struct AgeStructuredRow {
+ int year = 0;
+ double recruitment = 0.0;
+ double total_biomass = 0.0;
+ double ssb_proxy = 0.0;
+ double depletion = 0.0;
+ double fbar = 0.0;
+ double catch_obs = 0.0;
+ double catch_hat = 0.0;
+ double index_obs = 0.0;
+ double index_hat = 0.0;
+};
+
+double logistic_selectivity(double age, double a50, double slope) {
+ return 1.0 / (1.0 + std::exp(-slope * (age - a50)));
+}
+
+std::array default_weight_at_age() {
+ return {0.40, 0.85, 1.35, 1.95, 2.60, 3.25, 3.85, 4.35, 4.75, 5.05};
+}
+
+std::array default_maturity_at_age() {
+ return {0.00, 0.10, 0.35, 0.65, 0.85, 0.95, 1.00, 1.00, 1.00, 1.00};
+}
+
+std::vector split_csv_line(const std::string& line) {
+ std::vector out;
+ std::stringstream ss(line);
+ std::string item;
+ while (std::getline(ss, item, ',')) {
+ out.push_back(item);
+ }
+ return out;
+}
+
+std::vector read_observations(const std::string& path) {
+ std::ifstream in(path);
+ if (!in) {
+ throw std::runtime_error("Could not open observations CSV: " + path);
+ }
+
+ std::string line;
+ std::getline(in, line);
+
+ std::vector out;
+ while (std::getline(in, line)) {
+ if (line.empty()) {
+ continue;
+ }
+
+ const auto fields = split_csv_line(line);
+ if (fields.size() != 13) {
+ throw std::runtime_error("Expected 13 columns in observations CSV");
+ }
+
+ Observation obs;
+ obs.year = std::stoi(fields[0]);
+ obs.catch_mt = std::stod(fields[1]);
+ obs.index = std::stod(fields[2]);
+ for (int a = 0; a < kAges; ++a) {
+ obs.age_comp[static_cast(a)] = std::stod(fields[3 + a]);
+ }
+ out.push_back(obs);
+ }
+
+ return out;
+}
+
+double biomass_from_numbers(const std::array& n,
+ const std::array& weight) {
+ double out = 0.0;
+ for (int a = 0; a < kAges; ++a) {
+ out += n[static_cast(a)] * weight[static_cast(a)];
+ }
+ return out;
+}
+
+double ssb_from_numbers(const std::array& n,
+ const std::array& weight,
+ const std::array& maturity) {
+ double out = 0.0;
+ for (int a = 0; a < kAges; ++a) {
+ out += n[static_cast(a)] *
+ weight[static_cast(a)] *
+ maturity[static_cast(a)];
+ }
+ return out;
+}
+
+std::array unfished_equilibrium_numbers(double r0, double m) {
+ std::array n{};
+ n[0] = r0;
+ for (int a = 1; a < kAges; ++a) {
+ n[static_cast(a)] =
+ n[static_cast(a - 1)] * std::exp(-m);
+ }
+
+ // Plus group.
+ n[static_cast(kAges - 1)] /=
+ std::max(1.0e-12, 1.0 - std::exp(-m));
+
+ return n;
+}
+
+std::vector run_deterministic_age_structured_model(
+ const std::vector& observations,
+ const AgeStructuredParams& params) {
+ const auto weight = default_weight_at_age();
+ const auto maturity = default_maturity_at_age();
+
+ const double r0 = std::exp(params.log_r0);
+ const double m = std::exp(params.log_m);
+ const double fbar = std::exp(params.log_fbar);
+ const double q = std::exp(params.log_q);
+
+ std::array selectivity{};
+ for (int a = 0; a < kAges; ++a) {
+ selectivity[static_cast(a)] =
+ logistic_selectivity(static_cast(a + 1), params.sel_a50,
+ params.sel_slope);
+ }
+
+ std::array n = unfished_equilibrium_numbers(r0, m);
+ const double unfished_ssb = ssb_from_numbers(n, weight, maturity);
+
+ std::vector rows;
+ rows.reserve(observations.size());
+
+ for (const auto& obs : observations) {
+ const double biomass = biomass_from_numbers(n, weight);
+ const double ssb = ssb_from_numbers(n, weight, maturity);
+
+ double catch_hat = 0.0;
+ for (int a = 0; a < kAges; ++a) {
+ const auto i = static_cast(a);
+ const double f_a = fbar * selectivity[i];
+ const double z_a = m + f_a;
+ const double harvest_rate =
+ z_a > 0.0 ? (f_a / z_a) * (1.0 - std::exp(-z_a)) : 0.0;
+ catch_hat += n[i] * weight[i] * harvest_rate;
+ }
+
+ AgeStructuredRow row;
+ row.year = obs.year;
+ row.recruitment = r0;
+ row.total_biomass = biomass;
+ row.ssb_proxy = ssb;
+ row.depletion = ssb / std::max(1.0e-12, unfished_ssb);
+ row.fbar = fbar;
+ row.catch_obs = obs.catch_mt;
+ row.catch_hat = catch_hat;
+ row.index_obs = obs.index;
+ row.index_hat = q * biomass;
+ rows.push_back(row);
+
+ std::array next{};
+ next[0] = r0;
+
+ for (int a = 1; a < kAges; ++a) {
+ const auto prev = static_cast(a - 1);
+ const double f_prev = fbar * selectivity[prev];
+ const double z_prev = m + f_prev;
+ next[static_cast(a)] = n[prev] * std::exp(-z_prev);
+ }
+
+ // Plus group survivor contribution.
+ {
+ const auto last = static_cast(kAges - 1);
+ const double f_last = fbar * selectivity[last];
+ const double z_last = m + f_last;
+ next[last] += n[last] * std::exp(-z_last);
+ }
+
+ n = next;
+ }
+
+ return rows;
+}
+
+void write_age_structured_rows(const std::string& path,
+ const std::vector& rows) {
+ std::ofstream out(path);
+ if (!out) {
+ throw std::runtime_error("Could not open output CSV: " + path);
+ }
+
+ out << "year,recruitment,total_biomass,ssb_proxy,depletion,Fbar,"
+ << "catch_obs,catch_hat,index_obs,index_hat\n";
+
+ out << std::fixed << std::setprecision(6);
+ for (const auto& row : rows) {
+ out << row.year << "," << row.recruitment << "," << row.total_biomass
+ << "," << row.ssb_proxy << "," << row.depletion << ","
+ << row.fbar << "," << row.catch_obs << "," << row.catch_hat << ","
+ << row.index_obs << "," << row.index_hat << "\n";
+ }
+}
+
+} // namespace sefsc_red_snapper
+
+int main() {
+ const std::string input_path =
+ "examples/NMFS/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv";
+ const std::string output_path =
+ "examples/NMFS/sefsc_red_snapper/outputs/age_structured_deterministic_trajectory.csv";
+
+ const auto observations = sefsc_red_snapper::read_observations(input_path);
+
+ sefsc_red_snapper::AgeStructuredParams params;
+ const auto rows =
+ sefsc_red_snapper::run_deterministic_age_structured_model(observations,
+ params);
+
+ sefsc_red_snapper::write_age_structured_rows(output_path, rows);
+
+ std::cout << "SEFSC red-snapper-style deterministic age-structured model\n";
+ std::cout << "observations: " << observations.size() << "\n";
+ std::cout << "wrote: " << output_path << "\n";
+
+ if (!rows.empty()) {
+ const auto& terminal = rows.back();
+ std::cout << "terminal total biomass: " << terminal.total_biomass << "\n";
+ std::cout << "terminal SSB proxy: " << terminal.ssb_proxy << "\n";
+ std::cout << "terminal depletion: " << terminal.depletion << "\n";
+ }
+
+ return 0;
+}
+CPP
+
+cat > "$BASE/run_red_snapper_age_structured.sh" <<'SH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+mkdir -p examples/NMFS/sefsc_red_snapper/outputs
+
+c++ -std=c++17 -O3 \
+ -I. \
+ -Iexternal/eigen \
+ -Icore \
+ -o examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured \
+ examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured.cpp
+
+./examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured
+SH
+chmod +x "$BASE/run_red_snapper_age_structured.sh"
+
+cat > "$BASE/validation/age_structured_deterministic_checklist.md" <<'MD'
+# Deterministic Age-Structured Checklist
+
+- [x] age classes 1-10+
+- [x] weight-at-age vector
+- [x] maturity-at-age vector
+- [x] logistic selectivity
+- [x] plus group
+- [x] catch prediction using Baranov catch equation
+- [x] biomass, SSB proxy, depletion, Fbar, index prediction
+- [ ] likelihood contributions
+- [ ] parameter estimation
+- [ ] recruitment deviations
+- [ ] Laplace/random-effect treatment
+- [ ] TMB comparison
+MD
+
+echo
+echo "Added deterministic age-structured model."
+echo
+echo "Run:"
+echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_age_structured.sh"
+echo " head examples/NMFS/sefsc_red_snapper/outputs/age_structured_deterministic_trajectory.csv"
diff --git a/add_sefsc_red_snapper_fitted_trajectory_v1.sh b/add_sefsc_red_snapper_fitted_trajectory_v1.sh
new file mode 100755
index 0000000..bb3a3a3
--- /dev/null
+++ b/add_sefsc_red_snapper_fitted_trajectory_v1.sh
@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+echo "== Add fitted trajectory output to SEFSC red snapper Quadra fit =="
+
+python3 - <<'PY'
+from pathlib import Path
+
+p = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp")
+s = p.read_text()
+
+marker = "} // namespace\n\nint main()"
+if "write_fitted_trajectory" not in s:
+ helper = r'''
+
+void write_fitted_trajectory(
+ const std::string& path,
+ const std::vector& observations,
+ const quadra::OptResult& fit) {
+ if (fit.par.size() < 3) {
+ throw std::runtime_error("Cannot write fitted trajectory: expected at least 3 fixed parameters");
+ }
+
+ sefsc_red_snapper::AgeStructuredParams params;
+ params.log_r0 = fit.par[0];
+ params.log_fbar = fit.par[1];
+ params.log_q = fit.par[2];
+
+ const auto rows =
+ sefsc_red_snapper::run_deterministic_age_structured_model(observations,
+ params);
+
+ std::ofstream out(path);
+ if (!out) {
+ throw std::runtime_error("Could not open fitted trajectory CSV: " + path);
+ }
+
+ out << "year,recruitment,total_biomass,ssb_proxy,depletion,Fbar,"
+ << "catch_obs,catch_hat,catch_log_residual,index_obs,index_hat,"
+ << "index_log_residual\n";
+
+ out << std::fixed << std::setprecision(6);
+
+ for (const auto& row : rows) {
+ const double catch_log_residual =
+ std::log(std::max(row.catch_obs, 1.0e-12)) -
+ std::log(std::max(row.catch_hat, 1.0e-12));
+ const double index_log_residual =
+ std::log(std::max(row.index_obs, 1.0e-12)) -
+ std::log(std::max(row.index_hat, 1.0e-12));
+
+ out << row.year << "," << row.recruitment << "," << row.total_biomass
+ << "," << row.ssb_proxy << "," << row.depletion << ","
+ << row.fbar << "," << row.catch_obs << "," << row.catch_hat
+ << "," << catch_log_residual << "," << row.index_obs << ","
+ << row.index_hat << "," << index_log_residual << "\n";
+ }
+}
+'''
+ if marker not in s:
+ raise SystemExit("Could not find helper insertion marker")
+ s = s.replace(marker, helper + "\n\n" + marker)
+
+old = ''' const std::string summary_path =
+ "examples/NMFS/sefsc_red_snapper/outputs/quadra_fit_summary.csv";
+'''
+new = ''' const std::string summary_path =
+ "examples/NMFS/sefsc_red_snapper/outputs/quadra_fit_summary.csv";
+ const std::string trajectory_path =
+ "examples/NMFS/sefsc_red_snapper/outputs/quadra_fitted_trajectory.csv";
+'''
+if new not in s:
+ if old not in s:
+ raise SystemExit("Could not find summary_path block")
+ s = s.replace(old, new)
+
+old = ''' sefsc_red_snapper::write_fit_summary(summary_path, fit);
+
+ std::cout << "SEFSC red-snapper-style Quadra fixed-effect fit\\n";
+'''
+new = ''' sefsc_red_snapper::write_fit_summary(summary_path, fit);
+ sefsc_red_snapper::write_fitted_trajectory(trajectory_path, observations, fit);
+
+ std::cout << "SEFSC red-snapper-style Quadra fixed-effect fit\\n";
+'''
+if new not in s:
+ if old not in s:
+ raise SystemExit("Could not find write_fit_summary call")
+ s = s.replace(old, new)
+
+old = ''' std::cout << "wrote: " << summary_path << "\\n";
+'''
+new = ''' std::cout << "wrote: " << summary_path << "\\n";
+ std::cout << "wrote: " << trajectory_path << "\\n";
+'''
+if new not in s:
+ if old not in s:
+ raise SystemExit("Could not find summary print")
+ s = s.replace(old, new)
+
+p.write_text(s)
+PY
+
+cat > examples/NMFS/sefsc_red_snapper/validation/fitted_trajectory_checklist.md <<'MD'
+# Fitted Trajectory Checklist
+
+- [x] fixed-effect fit summary written
+- [x] fitted deterministic trajectory written
+- [x] observed catch and predicted catch included
+- [x] observed index and predicted index included
+- [x] log residuals included
+- [ ] residual diagnostics summary
+- [ ] fitted trajectory plotted
+- [ ] age-composition likelihood added
+MD
+
+echo
+echo "Patched fitted trajectory output."
+echo
+echo "Run:"
+echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh"
+echo " head examples/NMFS/sefsc_red_snapper/outputs/quadra_fitted_trajectory.csv"
diff --git a/add_sefsc_red_snapper_level0_scaffold_v1.sh b/add_sefsc_red_snapper_level0_scaffold_v1.sh
new file mode 100755
index 0000000..962b25d
--- /dev/null
+++ b/add_sefsc_red_snapper_level0_scaffold_v1.sh
@@ -0,0 +1,291 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+echo "== Add SEFSC red snapper synthetic data and initial model scaffold =="
+
+BASE="examples/NMFS/sefsc_red_snapper"
+mkdir -p "$BASE"/{data,quadra,tmb,outputs,validation}
+
+cat > "$BASE/data/synthetic_red_snapper_observations.csv" <<'CSV'
+year,catch_mt,index,age1,age2,age3,age4,age5,age6,age7,age8,age9,age10
+1,220,0.82,0.18,0.21,0.19,0.15,0.10,0.07,0.04,0.03,0.02,0.01
+2,230,0.86,0.17,0.22,0.19,0.15,0.10,0.07,0.04,0.03,0.02,0.01
+3,245,0.89,0.16,0.22,0.20,0.15,0.10,0.07,0.04,0.03,0.02,0.01
+4,260,0.91,0.15,0.21,0.21,0.16,0.10,0.07,0.04,0.03,0.02,0.01
+5,275,0.93,0.15,0.20,0.21,0.16,0.11,0.07,0.04,0.03,0.02,0.01
+6,290,0.95,0.14,0.20,0.21,0.17,0.11,0.07,0.04,0.03,0.02,0.01
+7,305,0.96,0.14,0.19,0.21,0.17,0.11,0.08,0.04,0.03,0.02,0.01
+8,315,0.94,0.13,0.19,0.21,0.17,0.12,0.08,0.04,0.03,0.02,0.01
+9,320,0.91,0.13,0.18,0.21,0.18,0.12,0.08,0.04,0.03,0.02,0.01
+10,330,0.88,0.12,0.18,0.21,0.18,0.12,0.08,0.05,0.03,0.02,0.01
+11,335,0.84,0.12,0.17,0.21,0.18,0.13,0.08,0.05,0.03,0.02,0.01
+12,340,0.81,0.11,0.17,0.20,0.19,0.13,0.09,0.05,0.03,0.02,0.01
+13,330,0.80,0.12,0.17,0.20,0.18,0.13,0.09,0.05,0.03,0.02,0.01
+14,320,0.82,0.13,0.18,0.20,0.18,0.12,0.09,0.05,0.03,0.02,0.01
+15,310,0.85,0.14,0.18,0.20,0.17,0.12,0.08,0.05,0.03,0.02,0.01
+16,300,0.89,0.15,0.19,0.20,0.17,0.11,0.08,0.05,0.03,0.02,0.01
+17,295,0.93,0.16,0.19,0.20,0.16,0.11,0.08,0.05,0.03,0.02,0.01
+18,285,0.97,0.17,0.20,0.19,0.16,0.10,0.08,0.05,0.03,0.02,0.01
+19,275,1.01,0.18,0.20,0.19,0.15,0.10,0.08,0.05,0.03,0.01,0.01
+20,265,1.04,0.19,0.21,0.18,0.15,0.10,0.07,0.05,0.03,0.01,0.01
+CSV
+
+cat > "$BASE/data/red_snapper_projection_scenarios.csv" <<'CSV'
+scenario,projection_year,catch_mt
+zero_catch,21,0
+zero_catch,22,0
+zero_catch,23,0
+zero_catch,24,0
+zero_catch,25,0
+status_quo,21,265
+status_quo,22,265
+status_quo,23,265
+status_quo,24,265
+status_quo,25,265
+high_catch,21,340
+high_catch,22,340
+high_catch,23,340
+high_catch,24,340
+high_catch,25,340
+CSV
+
+cat > "$BASE/quadra/red_snapper_model.hpp" <<'CPP'
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+namespace sefsc_red_snapper {
+
+struct Observation {
+ int year = 0;
+ double catch_mt = 0.0;
+ double index = 0.0;
+ std::array age_comp{};
+};
+
+struct ProjectionScenario {
+ std::string scenario;
+ int projection_year = 0;
+ double catch_mt = 0.0;
+};
+
+struct DerivedRow {
+ int year = 0;
+ double biomass = 0.0;
+ double ssb_proxy = 0.0;
+ double depletion = 0.0;
+ double f_proxy = 0.0;
+ double index_hat = 0.0;
+};
+
+// Level-0 placeholder model:
+// This is intentionally minimal. The next patch should replace this with
+// Quadra AD/Laplace evaluation and recruitment deviations as random effects.
+class RedSnapperModel {
+ public:
+ explicit RedSnapperModel(std::vector obs)
+ : observations_(std::move(obs)) {}
+
+ const std::vector& observations() const { return observations_; }
+
+ std::vector deterministic_trajectory(double log_r0,
+ double log_q,
+ double log_f) const {
+ const double r0 = std::exp(log_r0);
+ const double q = std::exp(log_q);
+ const double f = std::exp(log_f);
+
+ std::vector out;
+ out.reserve(observations_.size());
+
+ double biomass = r0;
+ const double unfished = r0;
+
+ for (const auto& obs : observations_) {
+ biomass = std::max(1.0, biomass + 0.25 * r0 - obs.catch_mt - 0.05 * biomass);
+ DerivedRow row;
+ row.year = obs.year;
+ row.biomass = biomass;
+ row.ssb_proxy = 0.35 * biomass;
+ row.depletion = biomass / unfished;
+ row.f_proxy = f * obs.catch_mt / std::max(1.0, biomass);
+ row.index_hat = q * biomass;
+ out.push_back(row);
+ }
+
+ return out;
+ }
+
+ private:
+ std::vector observations_;
+};
+
+} // namespace sefsc_red_snapper
+CPP
+
+cat > "$BASE/quadra/red_snapper_level0.cpp" <<'CPP'
+#include "red_snapper_model.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+std::vector split_csv_line(const std::string& line) {
+ std::vector out;
+ std::stringstream ss(line);
+ std::string item;
+ while (std::getline(ss, item, ',')) {
+ out.push_back(item);
+ }
+ return out;
+}
+
+std::vector read_observations(const std::string& path) {
+ std::ifstream in(path);
+ if (!in) {
+ throw std::runtime_error("Could not open observations CSV: " + path);
+ }
+
+ std::string line;
+ std::getline(in, line); // header
+
+ std::vector out;
+ while (std::getline(in, line)) {
+ if (line.empty()) continue;
+ const auto fields = split_csv_line(line);
+ if (fields.size() != 13) {
+ throw std::runtime_error("Expected 13 columns in observations CSV");
+ }
+
+ sefsc_red_snapper::Observation obs;
+ obs.year = std::stoi(fields[0]);
+ obs.catch_mt = std::stod(fields[1]);
+ obs.index = std::stod(fields[2]);
+ for (std::size_t a = 0; a < obs.age_comp.size(); ++a) {
+ obs.age_comp[a] = std::stod(fields[3 + a]);
+ }
+ out.push_back(obs);
+ }
+ return out;
+}
+
+void write_derived_quantities(
+ const std::string& path,
+ const std::vector& rows) {
+ std::ofstream out(path);
+ out << "year,biomass,ssb_proxy,depletion,F_proxy,index_hat\n";
+ out << std::fixed << std::setprecision(6);
+ for (const auto& row : rows) {
+ out << row.year << "," << row.biomass << "," << row.ssb_proxy << ","
+ << row.depletion << "," << row.f_proxy << "," << row.index_hat << "\n";
+ }
+}
+
+} // namespace
+
+int main() {
+ const std::string input_path =
+ "examples/NMFS/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv";
+ const std::string output_path =
+ "examples/NMFS/sefsc_red_snapper/outputs/level0_derived_quantities.csv";
+
+ auto observations = read_observations(input_path);
+ sefsc_red_snapper::RedSnapperModel model(observations);
+
+ // Fixed placeholder values. Next patch should estimate these.
+ const double log_r0 = std::log(1400.0);
+ const double log_q = std::log(0.001);
+ const double log_f = std::log(0.25);
+
+ auto trajectory = model.deterministic_trajectory(log_r0, log_q, log_f);
+ write_derived_quantities(output_path, trajectory);
+
+ std::cout << "SEFSC red-snapper-style Level-0 scaffold\n";
+ std::cout << "observations: " << observations.size() << "\n";
+ std::cout << "wrote: " << output_path << "\n";
+
+ if (!trajectory.empty()) {
+ const auto& last = trajectory.back();
+ std::cout << "terminal biomass: " << last.biomass << "\n";
+ std::cout << "terminal depletion: " << last.depletion << "\n";
+ }
+
+ return 0;
+}
+CPP
+
+cat > "$BASE/tmb/red_snapper_tmb.cpp" <<'CPP'
+// Placeholder TMB reference implementation for the SEFSC red-snapper-style example.
+//
+// The next milestone should implement the same likelihood and derived quantities
+// as the Quadra model so objective values, estimates, random effects, and
+// uncertainty outputs can be compared side by side.
+
+template
+Type objective_function::operator()() {
+ return Type(0.0);
+}
+CPP
+
+cat > "$BASE/tmb/README.md" <<'MD'
+# TMB Reference Implementation
+
+This directory will contain the TMB comparison model for the SEFSC red-snapper-style example.
+
+The current file is a placeholder and should not be used as a scientific reference yet.
+MD
+
+cat > "$BASE/validation/level0_checklist.md" <<'MD'
+# Level-0 Checklist
+
+- [ ] deterministic age-structured dynamics implemented
+- [ ] synthetic catch observations read from `data/`
+- [ ] synthetic index observations read from `data/`
+- [ ] derived quantities written to `outputs/`
+- [ ] minimal runner compiles from a clean checkout
+- [ ] TMB reference implementation added
+- [ ] Quadra/TMB comparison table added
+MD
+
+cat > "$BASE/outputs/.gitignore" <<'EOF'
+*
+!.gitignore
+EOF
+
+cat > "$BASE/run_red_snapper_level0.sh" <<'SH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+mkdir -p examples/NMFS/sefsc_red_snapper/outputs
+
+c++ -std=c++17 -O3 \
+ -I. \
+ -Iexternal/eigen \
+ -Icore \
+ -o examples/NMFS/sefsc_red_snapper/quadra/red_snapper_level0 \
+ examples/NMFS/sefsc_red_snapper/quadra/red_snapper_level0.cpp
+
+./examples/NMFS/sefsc_red_snapper/quadra/red_snapper_level0
+SH
+chmod +x "$BASE/run_red_snapper_level0.sh"
+
+echo
+echo "Created initial SEFSC red snapper scaffold."
+echo
+echo "Run:"
+echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_level0.sh"
+echo
+echo "Then inspect:"
+echo " head examples/NMFS/sefsc_red_snapper/outputs/level0_derived_quantities.csv"
diff --git a/add_sefsc_red_snapper_objective_v1.sh b/add_sefsc_red_snapper_objective_v1.sh
new file mode 100755
index 0000000..3e1a027
--- /dev/null
+++ b/add_sefsc_red_snapper_objective_v1.sh
@@ -0,0 +1,222 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+echo "== Add SEFSC red snapper objective function scaffold =="
+
+BASE="examples/NMFS/sefsc_red_snapper"
+mkdir -p "$BASE"/{quadra,outputs,validation}
+
+cat > "$BASE/quadra/red_snapper_objective.hpp" <<'CPP'
+#pragma once
+
+#include "red_snapper_age_structured.hpp"
+
+#include
+#include
+#include
+#include
+#include
+
+namespace sefsc_red_snapper {
+
+struct ObjectiveOptions {
+ double sigma_log_index = 0.20;
+ double sigma_log_catch = 0.15;
+ double min_positive = 1.0e-12;
+};
+
+struct ObjectiveBreakdown {
+ double total = 0.0;
+ double index_nll = 0.0;
+ double catch_nll = 0.0;
+ int n_index = 0;
+ int n_catch = 0;
+};
+
+inline double square(double x) { return x * x; }
+
+inline double lognormal_nll_no_constant(double observed, double predicted,
+ double sigma, double min_positive) {
+ const double obs = std::max(observed, min_positive);
+ const double pred = std::max(predicted, min_positive);
+ const double z = (std::log(obs) - std::log(pred)) / sigma;
+ return 0.5 * square(z);
+}
+
+inline ObjectiveBreakdown evaluate_objective_breakdown(
+ const std::vector& observations,
+ const AgeStructuredParams& params,
+ const ObjectiveOptions& options = ObjectiveOptions{}) {
+ ObjectiveBreakdown out;
+
+ const auto rows = run_deterministic_age_structured_model(observations, params);
+ if (rows.size() != observations.size()) {
+ throw std::runtime_error("Objective trajectory/observation size mismatch");
+ }
+
+ for (std::size_t i = 0; i < observations.size(); ++i) {
+ const auto& obs = observations[i];
+ const auto& pred = rows[i];
+
+ if (std::isfinite(obs.index) && obs.index > 0.0) {
+ const double nll = lognormal_nll_no_constant(
+ obs.index, pred.index_hat, options.sigma_log_index,
+ options.min_positive);
+ out.index_nll += nll;
+ ++out.n_index;
+ }
+
+ if (std::isfinite(obs.catch_mt) && obs.catch_mt > 0.0) {
+ const double nll = lognormal_nll_no_constant(
+ obs.catch_mt, pred.catch_hat, options.sigma_log_catch,
+ options.min_positive);
+ out.catch_nll += nll;
+ ++out.n_catch;
+ }
+ }
+
+ out.total = out.index_nll + out.catch_nll;
+ return out;
+}
+
+inline double evaluate_objective(
+ const std::vector& observations,
+ const AgeStructuredParams& params,
+ const ObjectiveOptions& options = ObjectiveOptions{}) {
+ return evaluate_objective_breakdown(observations, params, options).total;
+}
+
+} // namespace sefsc_red_snapper
+CPP
+
+# Split reusable age-structured model logic into a header if it does not exist yet.
+if [[ ! -f "$BASE/quadra/red_snapper_age_structured.hpp" ]]; then
+ python3 - <<'PY'
+from pathlib import Path
+
+cpp = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured.cpp")
+hpp = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_age_structured.hpp")
+
+s = cpp.read_text()
+marker = "} // namespace sefsc_red_snapper\n\nint main()"
+idx = s.find(marker)
+if idx < 0:
+ raise SystemExit("Could not split red_snapper_age_structured.cpp into header")
+
+header_body = s[: idx + len("} // namespace sefsc_red_snapper\n")]
+header_body = header_body.replace('#include \n', '')
+header = "#pragma once\n\n" + header_body
+hpp.write_text(header)
+
+main_part = s[idx + len("} // namespace sefsc_red_snapper\n\n"):]
+new_cpp = '#include "red_snapper_age_structured.hpp"\n\n#include \n\n' + main_part
+cpp.write_text(new_cpp)
+print("created", hpp)
+print("rewrote", cpp)
+PY
+fi
+
+cat > "$BASE/quadra/evaluate_red_snapper_objective.cpp" <<'CPP'
+#include "red_snapper_objective.hpp"
+
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+void write_objective_summary(
+ const std::string& path,
+ const sefsc_red_snapper::ObjectiveBreakdown& obj,
+ const sefsc_red_snapper::AgeStructuredParams& params) {
+ std::ofstream out(path);
+ if (!out) {
+ throw std::runtime_error("Could not open objective summary CSV: " + path);
+ }
+
+ out << "field,value\n";
+ out << std::setprecision(12);
+ out << "objective_total," << obj.total << "\n";
+ out << "index_nll," << obj.index_nll << "\n";
+ out << "catch_nll," << obj.catch_nll << "\n";
+ out << "n_index," << obj.n_index << "\n";
+ out << "n_catch," << obj.n_catch << "\n";
+ out << "log_r0," << params.log_r0 << "\n";
+ out << "r0," << std::exp(params.log_r0) << "\n";
+ out << "log_m," << params.log_m << "\n";
+ out << "m," << std::exp(params.log_m) << "\n";
+ out << "log_fbar," << params.log_fbar << "\n";
+ out << "fbar," << std::exp(params.log_fbar) << "\n";
+ out << "log_q," << params.log_q << "\n";
+ out << "q," << std::exp(params.log_q) << "\n";
+ out << "sel_a50," << params.sel_a50 << "\n";
+ out << "sel_slope," << params.sel_slope << "\n";
+}
+
+} // namespace
+
+int main() {
+ const std::string input_path =
+ "examples/NMFS/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv";
+ const std::string summary_path =
+ "examples/NMFS/sefsc_red_snapper/outputs/objective_summary.csv";
+
+ const auto observations = sefsc_red_snapper::read_observations(input_path);
+
+ sefsc_red_snapper::AgeStructuredParams params;
+ sefsc_red_snapper::ObjectiveOptions options;
+
+ const auto breakdown =
+ sefsc_red_snapper::evaluate_objective_breakdown(observations, params,
+ options);
+
+ write_objective_summary(summary_path, breakdown, params);
+
+ std::cout << "SEFSC red-snapper-style objective scaffold\n";
+ std::cout << "objective_total: " << breakdown.total << "\n";
+ std::cout << "index_nll: " << breakdown.index_nll << "\n";
+ std::cout << "catch_nll: " << breakdown.catch_nll << "\n";
+ std::cout << "wrote: " << summary_path << "\n";
+
+ return 0;
+}
+CPP
+
+cat > "$BASE/run_red_snapper_objective.sh" <<'SH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+mkdir -p examples/NMFS/sefsc_red_snapper/outputs
+
+c++ -std=c++17 -O3 \
+ -I. \
+ -Iexternal/eigen \
+ -Icore \
+ -o examples/NMFS/sefsc_red_snapper/quadra/evaluate_red_snapper_objective \
+ examples/NMFS/sefsc_red_snapper/quadra/evaluate_red_snapper_objective.cpp
+
+./examples/NMFS/sefsc_red_snapper/quadra/evaluate_red_snapper_objective
+SH
+chmod +x "$BASE/run_red_snapper_objective.sh"
+
+cat > "$BASE/validation/objective_checklist.md" <<'MD'
+# Objective Function Checklist
+
+- [x] lognormal index likelihood
+- [x] lognormal catch likelihood
+- [x] objective breakdown output
+- [x] reusable objective header
+- [ ] parameter optimization
+- [ ] age-composition likelihood
+- [ ] recruitment-deviation prior
+- [ ] TMB objective parity
+MD
+
+echo
+echo "Added objective scaffold."
+echo
+echo "Run:"
+echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_objective.sh"
+echo " cat examples/NMFS/sefsc_red_snapper/outputs/objective_summary.csv"
diff --git a/add_sefsc_red_snapper_quadra_fit_v1.sh b/add_sefsc_red_snapper_quadra_fit_v1.sh
new file mode 100755
index 0000000..d352403
--- /dev/null
+++ b/add_sefsc_red_snapper_quadra_fit_v1.sh
@@ -0,0 +1,255 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+echo "== Add SEFSC red snapper Quadra optimizer adapter =="
+
+BASE="examples/NMFS/sefsc_red_snapper"
+mkdir -p "$BASE"/{quadra,outputs,validation}
+
+cat > "$BASE/quadra/red_snapper_quadra_fit.cpp" <<'CPP'
+#include "red_snapper_age_structured.hpp"
+
+#include "../../../core/optimizer.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace sefsc_red_snapper {
+
+template
+T exp_t(const T& x) {
+ using std::exp;
+ return exp(x);
+}
+
+template
+T log_t(const T& x) {
+ using std::log;
+ return log(x);
+}
+
+template
+T max_t(const T& x, double floor) {
+ return x > T(floor) ? x : T(floor);
+}
+
+template
+T square_t(const T& x) {
+ return x * x;
+}
+
+template
+T logistic_selectivity_t(const T& age, const T& a50, const T& slope) {
+ return T(1.0) / (T(1.0) + exp_t(-slope * (age - a50)));
+}
+
+class RedSnapperQuadraObjective {
+ public:
+ explicit RedSnapperQuadraObjective(std::vector observations)
+ : observations_(std::move(observations)) {}
+
+ template
+ T operator()(const std::vector& par) const {
+ if (par.size() < 3) {
+ throw std::runtime_error(
+ "RedSnapperQuadraObjective expected parameters: log_r0, log_fbar, log_q");
+ }
+
+ const T log_r0 = par[0];
+ const T log_fbar = par[1];
+ const T log_q = par[2];
+
+ const T r0 = exp_t(log_r0);
+ const T m = T(0.18);
+ const T fbar = exp_t(log_fbar);
+ const T q = exp_t(log_q);
+
+ const T sigma_log_index = T(0.20);
+ const T sigma_log_catch = T(0.15);
+ const double min_positive = 1.0e-12;
+
+ const auto weight = default_weight_at_age();
+ const auto maturity = default_maturity_at_age();
+
+ std::array selectivity{};
+ for (int a = 0; a < kAges; ++a) {
+ selectivity[static_cast(a)] =
+ logistic_selectivity_t(T(a + 1), T(4.0), T(1.2));
+ }
+
+ std::array n{};
+ n[0] = r0;
+ for (int a = 1; a < kAges; ++a) {
+ n[static_cast(a)] =
+ n[static_cast(a - 1)] * exp_t(-m);
+ }
+ n[static_cast(kAges - 1)] =
+ n[static_cast(kAges - 1)] /
+ (T(1.0) - exp_t(-m));
+
+ T nll = T(0.0);
+
+ for (const auto& obs : observations_) {
+ T biomass = T(0.0);
+ for (int a = 0; a < kAges; ++a) {
+ biomass = biomass +
+ n[static_cast(a)] *
+ T(weight[static_cast(a)]);
+ }
+
+ T catch_hat = T(0.0);
+ for (int a = 0; a < kAges; ++a) {
+ const auto i = static_cast(a);
+ const T f_a = fbar * selectivity[i];
+ const T z_a = m + f_a;
+ const T harvest_rate =
+ (f_a / z_a) * (T(1.0) - exp_t(-z_a));
+ catch_hat = catch_hat + n[i] * T(weight[i]) * harvest_rate;
+ }
+
+ const T index_hat = q * biomass;
+
+ if (obs.index > 0.0) {
+ const T z = (log_t(T(obs.index)) -
+ log_t(max_t(index_hat, min_positive))) /
+ sigma_log_index;
+ nll = nll + T(0.5) * square_t(z);
+ }
+
+ if (obs.catch_mt > 0.0) {
+ const T z = (log_t(T(obs.catch_mt)) -
+ log_t(max_t(catch_hat, min_positive))) /
+ sigma_log_catch;
+ nll = nll + T(0.5) * square_t(z);
+ }
+
+ std::array next{};
+ next[0] = r0;
+
+ for (int a = 1; a < kAges; ++a) {
+ const auto prev = static_cast(a - 1);
+ const T f_prev = fbar * selectivity[prev];
+ const T z_prev = m + f_prev;
+ next[static_cast(a)] = n[prev] * exp_t(-z_prev);
+ }
+
+ const auto last = static_cast(kAges - 1);
+ const T f_last = fbar * selectivity[last];
+ const T z_last = m + f_last;
+ next[last] = next[last] + n[last] * exp_t(-z_last);
+
+ n = next;
+ }
+
+ return nll;
+ }
+
+ private:
+ std::vector observations_;
+};
+
+void write_fit_summary(const std::string& path,
+ const quadra::OptResult& fit) {
+ std::ofstream out(path);
+ if (!out) {
+ throw std::runtime_error("Could not open fit summary CSV: " + path);
+ }
+
+ out << "field,value\n";
+ out << std::setprecision(12);
+ out << "objective," << fit.value << "\n";
+ out << "grad_norm," << fit.grad_norm << "\n";
+ out << "iterations," << fit.iterations << "\n";
+ out << "converged," << (fit.converged ? "yes" : "no") << "\n";
+ out << "message," << fit.message << "\n";
+
+ if (fit.par.size() >= 3) {
+ out << "log_r0," << fit.par[0] << "\n";
+ out << "r0," << std::exp(fit.par[0]) << "\n";
+ out << "log_fbar," << fit.par[1] << "\n";
+ out << "fbar," << std::exp(fit.par[1]) << "\n";
+ out << "log_q," << fit.par[2] << "\n";
+ out << "q," << std::exp(fit.par[2]) << "\n";
+ }
+}
+
+} // namespace sefsc_red_snapper
+
+int main() {
+ const std::string input_path =
+ "examples/NMFS/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv";
+ const std::string summary_path =
+ "examples/NMFS/sefsc_red_snapper/outputs/quadra_fit_summary.csv";
+
+ const auto observations = sefsc_red_snapper::read_observations(input_path);
+
+ sefsc_red_snapper::RedSnapperQuadraObjective objective(observations);
+
+ quadra::ParameterVector params;
+ params.add_fixed("log_r0", std::log(1200.0));
+ params.add_fixed("log_fbar", std::log(0.025));
+ params.add_fixed("log_q", std::log(0.00005));
+
+ quadra::LaplaceOptions opts;
+ opts.max_outer_iterations = 200;
+ opts.fixed_grad_tol = 1.0e-8;
+
+ auto fit = quadra::optimize_lbfgs(objective, params, opts);
+
+ sefsc_red_snapper::write_fit_summary(summary_path, fit);
+
+ std::cout << "SEFSC red-snapper-style Quadra fixed-effect fit\n";
+ std::cout << "objective: " << fit.value << "\n";
+ std::cout << "grad_norm: " << fit.grad_norm << "\n";
+ std::cout << "converged: " << (fit.converged ? "yes" : "no") << "\n";
+ std::cout << "message: " << fit.message << "\n";
+ std::cout << "wrote: " << summary_path << "\n";
+
+ return 0;
+}
+CPP
+
+cat > "$BASE/run_red_snapper_quadra_fit.sh" <<'SH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+mkdir -p examples/NMFS/sefsc_red_snapper/outputs
+
+c++ -std=c++17 -O3 \
+ -I. \
+ -Iexternal/eigen \
+ -Icore \
+ -Iexternal/LBFGSpp/include \
+ -o examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit \
+ examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp
+
+./examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit
+SH
+chmod +x "$BASE/run_red_snapper_quadra_fit.sh"
+
+cat > "$BASE/validation/quadra_fit_checklist.md" <<'MD'
+# Quadra Fit Checklist
+
+- [x] fixed-effect objective adapter added
+- [x] Quadra optimizer path used
+- [ ] fixed-effect fit compiles against current local API
+- [ ] fit summary output validated
+- [ ] derived trajectory generated at fitted parameters
+- [ ] age-composition likelihood added
+- [ ] recruitment deviations added as random effects
+- [ ] Laplace uncertainty added
+MD
+
+echo
+echo "Added Quadra optimizer adapter."
+echo
+echo "Run:"
+echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh"
+echo
+echo "If the compile fails, inspect the current optimizer API with:"
+echo " grep -R \"add_fixed\\|optimize_lbfgs\\|struct OptResult\\|struct LaplaceOptions\" -n core examples | head -80"
diff --git a/add_sefsc_red_snapper_recruitment_devs_v1.sh b/add_sefsc_red_snapper_recruitment_devs_v1.sh
new file mode 100755
index 0000000..ff69f7d
--- /dev/null
+++ b/add_sefsc_red_snapper_recruitment_devs_v1.sh
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+echo "== Add recruitment deviations as random effects to SEFSC red snapper Quadra fit =="
+
+python3 - <<'PY'
+from pathlib import Path
+
+p = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp")
+s = p.read_text()
+
+s = s.replace(
+''' if (par.size() < 5)
+ {
+ throw std::runtime_error(
+ "RedSnapperQuadraObjective expected parameters: log_r0, log_fbar, log_q, logit_sel_a50, log_sel_slope");
+ }
+''',
+''' if (par.size() < 5 + observations_.size())
+ {
+ throw std::runtime_error(
+ "RedSnapperQuadraObjective expected parameters: log_r0, log_fbar, log_q, logit_sel_a50, log_sel_slope, log_rec_dev[year]");
+ }
+''',
+1)
+
+s = s.replace(
+''' const T sigma_log_index = T(0.20);
+ const T sigma_log_catch = T(0.15);
+''',
+''' const T sigma_log_index = T(0.20);
+ const T sigma_log_catch = T(0.15);
+ const T sigma_rec_dev = T(0.35);
+''',
+1)
+
+old = ''' nll = nll + normal_prior(log_sel_slope, std::log(1.2), 0.35);
+
+ for (const auto& obs : observations_) {
+'''
+new = ''' nll = nll + normal_prior(log_sel_slope, std::log(1.2), 0.35);
+
+ for (std::size_t t = 0; t < observations_.size(); ++t) {
+ const auto& obs = observations_[t];
+ const T rec_dev = par[5 + t];
+ nll = nll + T(0.5) * square_t(rec_dev / sigma_rec_dev);
+'''
+if old not in s:
+ raise SystemExit("Could not find start of observation loop / prior block")
+s = s.replace(old, new, 1)
+
+s = s.replace(
+''' std::array next{};
+ next[0] = r0;
+''',
+''' std::array next{};
+ next[0] = r0 * exp_t(rec_dev);
+''',
+1)
+
+if 'log_rec_dev_' not in s:
+ anchor = ''' params.add({"log_sel_slope", std::log(1.2), quadra::ParameterTransform::Identity, false});
+'''
+ insert = anchor + '''
+ for (std::size_t t = 0; t < observations.size(); ++t) {
+ params.add({"log_rec_dev_" + std::to_string(t + 1),
+ 0.0,
+ quadra::ParameterTransform::Identity,
+ true});
+ }
+'''
+ if anchor not in s:
+ raise SystemExit("Could not find log_sel_slope params.add anchor")
+ s = s.replace(anchor, insert, 1)
+
+p.write_text(s)
+PY
+
+cat > examples/NMFS/sefsc_red_snapper/validation/recruitment_deviation_laplace_checklist.md <<'MD'
+# Recruitment-Deviation Laplace Checklist
+
+- [x] one recruitment deviation random effect per fitted year
+- [x] Gaussian recruitment-deviation prior
+- [x] annual recruitment uses exp(log_rec_dev_t)
+- [x] random effects passed through Quadra ParameterVector
+- [ ] fitted recruitment deviations written
+- [ ] random-effect trajectory written
+- [ ] biomass/depletion uncertainty
+- [ ] selected inverse diagnostics
+MD
+
+echo
+echo "Patched recruitment deviations as random effects."
+echo
+echo "Run:"
+echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh"
+echo " cat examples/NMFS/sefsc_red_snapper/outputs/quadra_fit_summary.csv"
diff --git a/add_sefsc_red_snapper_residual_diagnostics_v1.sh b/add_sefsc_red_snapper_residual_diagnostics_v1.sh
new file mode 100755
index 0000000..ff0d3bb
--- /dev/null
+++ b/add_sefsc_red_snapper_residual_diagnostics_v1.sh
@@ -0,0 +1,104 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+python3 - <<'PY'
+from pathlib import Path
+
+p = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp")
+s = p.read_text()
+
+if "ResidualDiagnostics" not in s:
+ helper = r'''
+struct ResidualDiagnostics {
+ int n = 0;
+ double catch_rmse_log = 0.0;
+ double index_rmse_log = 0.0;
+ double catch_mean_log_residual = 0.0;
+ double index_mean_log_residual = 0.0;
+ double max_abs_catch_log_residual = 0.0;
+ double max_abs_index_log_residual = 0.0;
+};
+
+void write_residual_diagnostics(
+ const std::string& path,
+ const std::vector& observations,
+ const quadra::OptResult& fit) {
+ sefsc_red_snapper::AgeStructuredParams params;
+ params.log_r0 = fit.par[0];
+ params.log_fbar = fit.par[1];
+ params.log_q = fit.par[2];
+
+ const auto rows =
+ sefsc_red_snapper::run_deterministic_age_structured_model(observations,
+ params);
+
+ ResidualDiagnostics d;
+ d.n = static_cast(rows.size());
+
+ double catch_sum = 0.0, catch_ss = 0.0;
+ double index_sum = 0.0, index_ss = 0.0;
+
+ for (const auto& row : rows) {
+ const double cr = std::log(std::max(row.catch_obs, 1.0e-12)) -
+ std::log(std::max(row.catch_hat, 1.0e-12));
+ const double ir = std::log(std::max(row.index_obs, 1.0e-12)) -
+ std::log(std::max(row.index_hat, 1.0e-12));
+
+ catch_sum += cr;
+ catch_ss += cr * cr;
+ index_sum += ir;
+ index_ss += ir * ir;
+
+ d.max_abs_catch_log_residual =
+ std::max(d.max_abs_catch_log_residual, std::abs(cr));
+ d.max_abs_index_log_residual =
+ std::max(d.max_abs_index_log_residual, std::abs(ir));
+ }
+
+ if (d.n > 0) {
+ d.catch_mean_log_residual = catch_sum / d.n;
+ d.index_mean_log_residual = index_sum / d.n;
+ d.catch_rmse_log = std::sqrt(catch_ss / d.n);
+ d.index_rmse_log = std::sqrt(index_ss / d.n);
+ }
+
+ std::ofstream out(path);
+ out << "metric,value,note\n";
+ out << std::setprecision(12);
+ out << "n," << d.n << ",number of fitted years\n";
+ out << "catch_rmse_log," << d.catch_rmse_log << ",root mean squared log catch residual\n";
+ out << "index_rmse_log," << d.index_rmse_log << ",root mean squared log index residual\n";
+ out << "catch_mean_log_residual," << d.catch_mean_log_residual << ",mean log observed minus predicted catch\n";
+ out << "index_mean_log_residual," << d.index_mean_log_residual << ",mean log observed minus predicted index\n";
+ out << "max_abs_catch_log_residual," << d.max_abs_catch_log_residual << ",maximum absolute log catch residual\n";
+ out << "max_abs_index_log_residual," << d.max_abs_index_log_residual << ",maximum absolute log index residual\n";
+}
+'''
+ s = s.replace("\nint main()", "\n" + helper + "\nint main()", 1)
+
+s = s.replace(
+ ' const std::string trajectory_path =\n'
+ ' "examples/NMFS/sefsc_red_snapper/outputs/quadra_fitted_trajectory.csv";',
+ ' const std::string trajectory_path =\n'
+ ' "examples/NMFS/sefsc_red_snapper/outputs/quadra_fitted_trajectory.csv";\n'
+ ' const std::string residual_diagnostics_path =\n'
+ ' "examples/NMFS/sefsc_red_snapper/outputs/quadra_fit_residual_diagnostics.csv";',
+ 1,
+)
+
+s = s.replace(
+ " write_fitted_trajectory(trajectory_path, observations, fit);\n",
+ " write_fitted_trajectory(trajectory_path, observations, fit);\n"
+ " write_residual_diagnostics(residual_diagnostics_path, observations, fit);\n",
+ 1,
+)
+
+s = s.replace(
+ ' std::cout << "wrote: " << trajectory_path << "\\n";\n',
+ ' std::cout << "wrote: " << trajectory_path << "\\n";\n'
+ ' std::cout << "wrote: " << residual_diagnostics_path << "\\n";\n',
+ 1,
+)
+
+p.write_text(s)
+PY
diff --git a/add_sefsc_red_snapper_selectivity_estimation_v1.sh b/add_sefsc_red_snapper_selectivity_estimation_v1.sh
new file mode 100755
index 0000000..2701aab
--- /dev/null
+++ b/add_sefsc_red_snapper_selectivity_estimation_v1.sh
@@ -0,0 +1,190 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+echo "== Add estimated selectivity parameters to SEFSC red snapper Quadra fit =="
+
+python3 - <<'PY'
+from pathlib import Path
+
+p = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp")
+s = p.read_text()
+
+if "invlogit_t" not in s:
+ old = """template
+T log_t(const T& x) {
+ using std::log;
+ return log(x);
+}
+
+template
+T max_t"""
+ new = """template
+T log_t(const T& x) {
+ using std::log;
+ return log(x);
+}
+
+template
+T invlogit_t(const T& x) {
+ return T(1.0) / (T(1.0) + exp_t(-x));
+}
+
+template
+T max_t"""
+ if old not in s:
+ raise SystemExit("Could not find log_t block")
+ s = s.replace(old, new, 1)
+
+s = s.replace(
+""" if (par.size() < 3) {
+ throw std::runtime_error(
+ "RedSnapperQuadraObjective expected parameters: log_r0, log_fbar, log_q");
+ }
+
+ const T log_r0 = par[0];
+ const T log_fbar = par[1];
+ const T log_q = par[2];
+""",
+""" if (par.size() < 5) {
+ throw std::runtime_error(
+ "RedSnapperQuadraObjective expected parameters: log_r0, log_fbar, log_q, logit_sel_a50, log_sel_slope");
+ }
+
+ const T log_r0 = par[0];
+ const T log_fbar = par[1];
+ const T log_q = par[2];
+ const T logit_sel_a50 = par[3];
+ const T log_sel_slope = par[4];
+""",
+1)
+
+s = s.replace(
+""" const T q = exp_t(log_q);
+
+ const T sigma_log_index = T(0.20);
+""",
+""" const T q = exp_t(log_q);
+ const T sel_a50 = T(1.0) + T(9.0) * invlogit_t(logit_sel_a50);
+ const T sel_slope = exp_t(log_sel_slope);
+
+ const T sigma_log_index = T(0.20);
+""",
+1)
+
+s = s.replace(
+" logistic_selectivity_t(T(a + 1), T(4.0), T(1.2));",
+" logistic_selectivity_t(T(a + 1), sel_a50, sel_slope);",
+1)
+
+# Only add regularization if not already added.
+if "normal_penalty" not in s:
+ s = s.replace(
+""" T nll = T(0.0);
+
+ for (const auto& obs : observations_) {
+""",
+""" T nll = T(0.0);
+
+ auto normal_penalty = [](const T& x, double mean, double sd) {
+ const T z = (x - T(mean)) / T(sd);
+ return T(0.5) * z * z;
+ };
+
+ nll = nll + normal_penalty(log_r0, std::log(1200.0), 1.0);
+ nll = nll + normal_penalty(log_fbar, std::log(0.025), 0.75);
+ nll = nll + normal_penalty(log_q, std::log(0.00005), 1.0);
+ nll = nll + normal_penalty(sel_a50, 4.0, 2.0);
+ nll = nll + normal_penalty(log_sel_slope, std::log(1.2), 1.0);
+
+ for (const auto& obs : observations_) {
+""",
+1)
+
+if "logit_sel_a50" not in s.split("void write_fit_summary", 1)[1].split("}", 1)[0]:
+ s = s.replace(
+""" if (fit.par.size() >= 3) {
+ out << "log_r0," << fit.par[0] << "\\n";
+ out << "r0," << std::exp(fit.par[0]) << "\\n";
+ out << "log_fbar," << fit.par[1] << "\\n";
+ out << "fbar," << std::exp(fit.par[1]) << "\\n";
+ out << "log_q," << fit.par[2] << "\\n";
+ out << "q," << std::exp(fit.par[2]) << "\\n";
+ }
+""",
+""" if (fit.par.size() >= 3) {
+ out << "log_r0," << fit.par[0] << "\\n";
+ out << "r0," << std::exp(fit.par[0]) << "\\n";
+ out << "log_fbar," << fit.par[1] << "\\n";
+ out << "fbar," << std::exp(fit.par[1]) << "\\n";
+ out << "log_q," << fit.par[2] << "\\n";
+ out << "q," << std::exp(fit.par[2]) << "\\n";
+ }
+
+ if (fit.par.size() >= 5) {
+ const double sel_a50 = 1.0 + 9.0 / (1.0 + std::exp(-fit.par[3]));
+ const double sel_slope = std::exp(fit.par[4]);
+ out << "logit_sel_a50," << fit.par[3] << "\\n";
+ out << "sel_a50," << sel_a50 << "\\n";
+ out << "log_sel_slope," << fit.par[4] << "\\n";
+ out << "sel_slope," << sel_slope << "\\n";
+ }
+""",
+1)
+
+# Add selectivity mapping after trajectory params.log_q assignment in all helpers.
+s = s.replace(
+""" params.log_r0 = fit.par[0];
+ params.log_fbar = fit.par[1];
+ params.log_q = fit.par[2];
+
+ const auto rows =
+""",
+""" params.log_r0 = fit.par[0];
+ params.log_fbar = fit.par[1];
+ params.log_q = fit.par[2];
+ if (fit.par.size() >= 5) {
+ params.sel_a50 = 1.0 + 9.0 / (1.0 + std::exp(-fit.par[3]));
+ params.sel_slope = std::exp(fit.par[4]);
+ }
+
+ const auto rows =
+""")
+
+# Add parameters if missing.
+if 'params.add({"logit_sel_a50"' not in s:
+ s = s.replace(
+""" params.add({"log_r0", std::log(1200.0), quadra::ParameterTransform::Identity, false});
+ params.add({"log_fbar", std::log(0.025), quadra::ParameterTransform::Identity, false});
+ params.add({"log_q", std::log(0.00005), quadra::ParameterTransform::Identity, false});
+""",
+""" params.add({"log_r0", std::log(1200.0), quadra::ParameterTransform::Identity, false});
+ params.add({"log_fbar", std::log(0.025), quadra::ParameterTransform::Identity, false});
+ params.add({"log_q", std::log(0.00005), quadra::ParameterTransform::Identity, false});
+ params.add({"logit_sel_a50", 0.0, quadra::ParameterTransform::Identity, false});
+ params.add({"log_sel_slope", std::log(1.2), quadra::ParameterTransform::Identity, false});
+""",
+1)
+
+p.write_text(s)
+PY
+
+cat > examples/NMFS/sefsc_red_snapper/validation/selectivity_estimation_checklist.md <<'MD'
+# Selectivity Estimation Checklist
+
+- [x] estimated selectivity a50 fixed effect added
+- [x] estimated selectivity slope fixed effect added
+- [x] bounded a50 transform added
+- [x] positive slope transform added
+- [x] weak selectivity regularization added
+- [x] fitted selectivity parameters written to summary
+- [ ] age-composition residuals by age/year
+- [ ] selectivity-at-age output
+- [ ] Dirichlet-multinomial option
+MD
+
+echo
+echo "Patched selectivity estimation."
+echo
+echo "Run:"
+echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh"
+echo " cat examples/NMFS/sefsc_red_snapper/outputs/quadra_fit_summary.csv"
diff --git a/add_sefsc_red_snapper_selectivity_output_v1.sh b/add_sefsc_red_snapper_selectivity_output_v1.sh
new file mode 100755
index 0000000..f85a88c
--- /dev/null
+++ b/add_sefsc_red_snapper_selectivity_output_v1.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+python3 - <<'PY'
+from pathlib import Path
+
+p = Path("examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp")
+s = p.read_text()
+
+if "write_selectivity_at_age" in s:
+ print("Already installed")
+ raise SystemExit(0)
+
+anchor = """
+void write_residual_diagnostics(
+"""
+
+helper = r'''
+void write_selectivity_at_age(
+ const std::string& path,
+ const quadra::OptResult& fit)
+{
+ if (fit.par.size() < 5) {
+ return;
+ }
+
+ const double a50 =
+ 1.0 + 9.0 / (1.0 + std::exp(-fit.par[3]));
+ const double slope =
+ std::exp(fit.par[4]);
+
+ std::ofstream out(path);
+
+ out << "age,selectivity\n";
+
+ for (int age = 1; age <= kAges; ++age) {
+ const double sel =
+ 1.0 / (1.0 + std::exp(-slope * (age - a50)));
+
+ out << age << "," << sel << "\n";
+ }
+}
+
+'''
+
+if anchor not in s:
+ raise SystemExit("Could not find residual diagnostics anchor")
+
+s = s.replace(anchor, helper + "\n" + anchor, 1)
+
+call_anchor = """
+ write_residual_diagnostics(
+ diagnostics_path,
+ observations,
+ fit);
+
+ std::cout << "wrote: " << diagnostics_path << std::endl;
+"""
+
+call_block = r'''
+ const std::string selectivity_path =
+ "examples/NMFS/sefsc_red_snapper/outputs/selectivity_at_age.csv";
+
+ write_selectivity_at_age(
+ selectivity_path,
+ fit);
+
+ std::cout << "wrote: "
+ << selectivity_path
+ << std::endl;
+
+'''
+
+if call_anchor not in s:
+ raise SystemExit("Could not find diagnostics call block")
+
+s = s.replace(call_anchor,
+ call_anchor + call_block,
+ 1)
+
+p.write_text(s)
+PY
+
+echo
+echo "Installed selectivity-at-age output."
+echo
+echo "Run:"
+echo " ./examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh"
+echo " cat examples/NMFS/sefsc_red_snapper/outputs/selectivity_at_age.csv"
diff --git a/add_sefsc_red_snapper_tmb_comparison_v1.sh b/add_sefsc_red_snapper_tmb_comparison_v1.sh
new file mode 100755
index 0000000..a400f8a
--- /dev/null
+++ b/add_sefsc_red_snapper_tmb_comparison_v1.sh
@@ -0,0 +1,277 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+echo "== Add SEFSC red snapper Quadra vs TMB comparison scaffold =="
+
+mkdir -p examples/NMFS/sefsc_red_snapper/tmb
+mkdir -p examples/NMFS/sefsc_red_snapper/outputs
+
+cat > examples/NMFS/sefsc_red_snapper/tmb/red_snapper_tmb.cpp <<'CPP'
+#include
+
+template
+Type square(Type x) { return x * x; }
+
+template
+Type invlogit(Type x) {
+ return Type(1.0) / (Type(1.0) + exp(-x));
+}
+
+template
+Type logistic_selectivity(Type age, Type a50, Type slope) {
+ return Type(1.0) / (Type(1.0) + exp(-slope * (age - a50)));
+}
+
+template
+objective_function::operator()() {
+ DATA_VECTOR(catch_obs);
+ DATA_VECTOR(index_obs);
+ DATA_MATRIX(age_comp_obs);
+
+ PARAMETER(log_r0);
+ PARAMETER(log_fbar);
+ PARAMETER(log_q);
+ PARAMETER(logit_sel_a50);
+ PARAMETER(log_sel_slope);
+ PARAMETER_VECTOR(log_rec_dev);
+
+ const int n_years = catch_obs.size();
+ const int n_ages = age_comp_obs.cols();
+
+ Type r0 = exp(log_r0);
+ Type m = Type(0.18);
+ Type fbar = exp(log_fbar);
+ Type q = exp(log_q);
+ Type sel_a50 = Type(1.0) + Type(9.0) * invlogit(logit_sel_a50);
+ Type sel_slope = exp(log_sel_slope);
+
+ Type sigma_log_index = Type(0.20);
+ Type sigma_log_catch = Type(0.15);
+ Type sigma_rec_dev = Type(0.35);
+ Type age_comp_effective_n = Type(2.0);
+ Type min_positive = Type(1.0e-12);
+
+ vector weight(n_ages);
+ for (int a = 0; a < n_ages; ++a) {
+ weight(a) = Type(0.35) * pow(Type(a + 1), Type(2.8));
+ }
+
+ vector sel(n_ages);
+ for (int a = 0; a < n_ages; ++a) {
+ sel(a) = logistic_selectivity(Type(a + 1), sel_a50, sel_slope);
+ }
+
+ vector n(n_ages);
+ n(0) = r0;
+ for (int a = 1; a < n_ages; ++a) {
+ n(a) = n(a - 1) * exp(-m);
+ }
+ n(n_ages - 1) = n(n_ages - 1) / (Type(1.0) - exp(-m));
+
+ Type nll = Type(0.0);
+
+ nll += Type(0.5) * square((log_r0 - Type(std::log(1200.0))) / Type(1.0));
+ nll += Type(0.5) * square((log_fbar - Type(std::log(0.025))) / Type(0.75));
+ nll += Type(0.5) * square((log_q - Type(std::log(0.00005))) / Type(1.0));
+ nll += Type(0.5) * square((sel_a50 - Type(4.0)) / Type(0.75));
+ nll += Type(0.5) * square((log_sel_slope - Type(std::log(1.2))) / Type(0.35));
+
+ for (int y = 0; y < n_years; ++y) {
+ Type rec_dev = log_rec_dev(y);
+ nll += Type(0.5) * square(rec_dev / sigma_rec_dev);
+
+ Type total_biomass = Type(0.0);
+ Type catch_hat = Type(0.0);
+ Type selected_sum = Type(0.0);
+ vector pred_age_comp(n_ages);
+
+ for (int a = 0; a < n_ages; ++a) {
+ Type fa = fbar * sel(a);
+ Type za = m + fa;
+ total_biomass += n(a) * weight(a);
+ catch_hat += n(a) * weight(a) * fa / za * (Type(1.0) - exp(-za));
+ pred_age_comp(a) = n(a) * sel(a);
+ selected_sum += pred_age_comp(a);
+ }
+
+ Type index_hat = q * total_biomass;
+
+ if (index_obs(y) > 0.0) {
+ Type z = (log(Type(index_obs(y))) - log(CppAD::CondExpGt(index_hat, min_positive, index_hat, min_positive))) / sigma_log_index;
+ nll += Type(0.5) * z * z;
+ }
+
+ if (catch_obs(y) > 0.0) {
+ Type z = (log(Type(catch_obs(y))) - log(CppAD::CondExpGt(catch_hat, min_positive, catch_hat, min_positive))) / sigma_log_catch;
+ nll += Type(0.5) * z * z;
+ }
+
+ for (int a = 0; a < n_ages; ++a) {
+ pred_age_comp(a) = pred_age_comp(a) / CppAD::CondExpGt(selected_sum, min_positive, selected_sum, min_positive);
+ Type obs = age_comp_obs(y, a);
+ if (obs > 0.0) {
+ nll -= age_comp_effective_n * obs * log(CppAD::CondExpGt(pred_age_comp(a), min_positive, pred_age_comp(a), min_positive));
+ }
+ }
+
+ vector next(n_ages);
+ next.setZero();
+ next(0) = r0 * exp(rec_dev);
+
+ for (int a = 1; a < n_ages; ++a) {
+ Type f_prev = fbar * sel(a - 1);
+ Type z_prev = m + f_prev;
+ next(a) = n(a - 1) * exp(-z_prev);
+ }
+
+ Type f_last = fbar * sel(n_ages - 1);
+ Type z_last = m + f_last;
+ next(n_ages - 1) += n(n_ages - 1) * exp(-z_last);
+
+ n = next;
+ }
+
+ return nll;
+}
+CPP
+
+cat > examples/NMFS/sefsc_red_snapper/tmb/run_red_snapper_tmb_fit.R <<'RSCRIPT'
+#!/usr/bin/env Rscript
+
+suppressPackageStartupMessages(library(TMB))
+
+data_candidates <- c(
+ "examples/NMFS/sefsc_red_snapper/data/red_snapper_synthetic_observations.csv",
+ "examples/NMFS/sefsc_red_snapper/data/synthetic_red_snapper_observations.csv",
+ "examples/NMFS/sefsc_red_snapper/data/red_snapper_observations.csv"
+)
+
+data_path <- data_candidates[file.exists(data_candidates)][1]
+if (is.na(data_path)) {
+ stop("Could not find SEFSC red snapper observation CSV")
+}
+
+obs <- read.csv(data_path)
+
+catch_col <- grep("catch", names(obs), value = TRUE)[1]
+index_col <- grep("index", names(obs), value = TRUE)[1]
+age_cols <- grep("^age_|^age[0-9]+|comp", names(obs), value = TRUE)
+age_cols <- age_cols[sapply(obs[age_cols], is.numeric)]
+
+if (is.na(catch_col) || is.na(index_col) || length(age_cols) == 0) {
+ stop("Could not infer catch/index/age-composition columns from data CSV")
+}
+
+catch_obs <- as.numeric(obs[[catch_col]])
+index_obs <- as.numeric(obs[[index_col]])
+age_comp_obs <- as.matrix(obs[, age_cols, drop = FALSE])
+age_comp_obs <- age_comp_obs / rowSums(age_comp_obs)
+
+cpp <- "examples/NMFS/sefsc_red_snapper/tmb/red_snapper_tmb.cpp"
+TMB::compile(cpp)
+dyn.load(TMB::dynlib(sub("\\.cpp$", "", cpp)))
+
+parameters <- list(
+ log_r0 = log(1200.0),
+ log_fbar = log(0.025),
+ log_q = log(0.00005),
+ logit_sel_a50 = 0.0,
+ log_sel_slope = log(1.2),
+ log_rec_dev = rep(0.0, length(catch_obs))
+)
+
+obj <- MakeADFun(
+ data = list(catch_obs = catch_obs, index_obs = index_obs, age_comp_obs = age_comp_obs),
+ parameters = parameters,
+ random = "log_rec_dev",
+ DLL = "red_snapper_tmb",
+ silent = TRUE
+)
+
+fit <- nlminb(obj$par, obj$fn, obj$gr, control = list(eval.max = 1000, iter.max = 1000))
+pl <- obj$env$parList(obj$env$last.par.best)
+
+summary_path <- "examples/NMFS/sefsc_red_snapper/outputs/tmb_fit_summary.csv"
+out <- data.frame(
+ field = c("objective", "convergence", "message", "log_r0", "r0",
+ "log_fbar", "fbar", "log_q", "q", "logit_sel_a50",
+ "sel_a50", "log_sel_slope", "sel_slope", "random_effects"),
+ value = c(fit$objective, fit$convergence, fit$message,
+ pl$log_r0, exp(pl$log_r0),
+ pl$log_fbar, exp(pl$log_fbar),
+ pl$log_q, exp(pl$log_q),
+ pl$logit_sel_a50,
+ 1.0 + 9.0 / (1.0 + exp(-pl$logit_sel_a50)),
+ pl$log_sel_slope, exp(pl$log_sel_slope),
+ length(pl$log_rec_dev))
+)
+write.csv(out, summary_path, row.names = FALSE, quote = FALSE)
+
+rec_path <- "examples/NMFS/sefsc_red_snapper/outputs/tmb_recruitment_deviations.csv"
+write.csv(data.frame(year = seq_along(pl$log_rec_dev),
+ log_rec_dev = as.numeric(pl$log_rec_dev),
+ rec_multiplier = exp(as.numeric(pl$log_rec_dev))),
+ rec_path, row.names = FALSE, quote = FALSE)
+
+cat("wrote:", summary_path, "\n")
+cat("wrote:", rec_path, "\n")
+RSCRIPT
+chmod +x examples/NMFS/sefsc_red_snapper/tmb/run_red_snapper_tmb_fit.R
+
+cat > examples/NMFS/sefsc_red_snapper/compare_quadra_tmb_fit.py <<'PY'
+#!/usr/bin/env python3
+from pathlib import Path
+import csv
+import math
+
+out = Path("examples/NMFS/sefsc_red_snapper/outputs")
+
+def read_summary(path):
+ d = {}
+ with open(path) as f:
+ for row in csv.DictReader(f):
+ try:
+ d[row["field"]] = float(row["value"])
+ except Exception:
+ d[row["field"]] = row["value"]
+ return d
+
+q = read_summary(out / "quadra_fit_summary.csv")
+t = read_summary(out / "tmb_fit_summary.csv")
+
+fields = ["objective", "r0", "fbar", "q", "sel_a50", "sel_slope", "random_effects"]
+path = out / "quadra_vs_tmb_fit_comparison.csv"
+
+with open(path, "w", newline="") as f:
+ w = csv.writer(f)
+ w.writerow(["field", "quadra", "tmb", "difference", "relative_difference"])
+ for field in fields:
+ qv = q.get(field, "")
+ tv = t.get(field, "")
+ diff = ""
+ rel = ""
+ if isinstance(qv, float) and isinstance(tv, float):
+ diff = qv - tv
+ rel = diff / tv if tv != 0 and math.isfinite(tv) else ""
+ w.writerow([field, qv, tv, diff, rel])
+
+print(f"wrote: {path}")
+PY
+chmod +x examples/NMFS/sefsc_red_snapper/compare_quadra_tmb_fit.py
+
+cat > examples/NMFS/sefsc_red_snapper/run_quadra_vs_tmb_comparison.sh <<'SH'
+#!/usr/bin/env bash
+set -euo pipefail
+
+./examples/NMFS/sefsc_red_snapper/run_red_snapper_quadra_fit.sh
+Rscript examples/NMFS/sefsc_red_snapper/tmb/run_red_snapper_tmb_fit.R
+python3 examples/NMFS/sefsc_red_snapper/compare_quadra_tmb_fit.py
+cat examples/NMFS/sefsc_red_snapper/outputs/quadra_vs_tmb_fit_comparison.csv
+SH
+chmod +x examples/NMFS/sefsc_red_snapper/run_quadra_vs_tmb_comparison.sh
+
+echo
+echo "Installed Quadra vs TMB comparison scaffold."
+echo
+echo "Run:"
+echo " ./examples/NMFS/sefsc_red_snapper/run_quadra_vs_tmb_comparison.sh"
diff --git a/bad_laplace_tail_removed.txt b/bad_laplace_tail_removed.txt
new file mode 100644
index 0000000..4c50c4d
--- /dev/null
+++ b/bad_laplace_tail_removed.txt
@@ -0,0 +1,87 @@
+ const auto timing_hdot_end = std::chrono::steady_clock::now();
+ }
+ const auto timing_hdot_start = std::chrono::steady_clock::now();
+
+ Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size());
+
+ const auto Hdots = random_hessian_directional_exact_all(
+ model, params, theta, u_hat, dU, get_pattern_for_logdet);
+
+ for (Eigen::Index i = 0; i < theta.size(); ++i)
+ {
+ const Eigen::SparseMatrix &Hdot =
+ Hdots[static_cast(i)];
+
+ grad[i] =
+ 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options);
+ }
+
+ const auto timing_hdot_end = std::chrono::steady_clock::now();
+#endif
+
+#ifdef QUADRA_VALIDATE_EXACT_GRADIENT_WORKSPACE
+ auto selected_inverse = [&](int row, int col) -> double
+ {
+ Eigen::VectorXd e = Eigen::VectorXd::Zero(H.rows());
+ e[col] = 1.0;
+ Eigen::VectorXd x = solver.solve(e);
+ return x[row];
+ };
+
+ const Eigen::VectorXd workspace_trace =
+ random_hessian_trace_terms_exact_workspace(
+ model, params, theta, u_hat, dU, get_pattern_for_logdet,
+ selected_inverse);
+
+ Eigen::VectorXd trusted_trace = Eigen::VectorXd::Zero(theta.size());
+ for (Eigen::Index ii = 0; ii < theta.size(); ++ii)
+ {
+ trusted_trace[ii] = 2.0 * grad[ii];
+ }
+
+ const double workspace_rel_err =
+ (workspace_trace - trusted_trace).norm() /
+ std::max(1.0e-12, trusted_trace.norm());
+
+ std::cout << "ExactGradientWorkspace trace rel_err="
+ << workspace_rel_err
+ << " workspace_norm=" << workspace_trace.norm()
+ << " trusted_norm=" << trusted_trace.norm()
+ << "\n";
+#endif
+
+ // Restore baseline state for caller hygiene.
+ inject_fixed_params(theta, params, fixed_idx);
+ inject_random_params(u, params, random_idx);
+
+ const auto timing_logdet_exact_end = std::chrono::steady_clock::now();
+ const double total_ms =
+ std::chrono::duration(
+ timing_logdet_exact_end - timing_logdet_exact_start)
+ .count();
+ const double baseline_ms =
+ std::chrono::duration(
+ timing_baseline_end - timing_baseline_start)
+ .count();
+ const double factor_ms =
+ std::chrono::duration(
+ timing_factor_end - timing_factor_start)
+ .count();
+ const double du_ms =
+ std::chrono::duration(
+ timing_du_end - timing_du_start)
+ .count();
+ const double hdot_ms =
+ std::chrono::duration(
+ timing_hdot_end - timing_hdot_start)
+ .count();
+
+#ifdef QUADRA_PROFILE_LAPLACE_LOGDET_GRADIENT
+ std::cout << "laplace_logdet_gradient_exact ms = " << total_ms
+ << " baseline=" << baseline_ms
+ << " factor=" << factor_ms
+ << " du=" << du_ms
+ << " hdot_trace=" << hdot_ms
+ << "\n";
+#endif
+ return grad;
diff --git a/cleanup_laplace_diagnostics_to_header.sh b/cleanup_laplace_diagnostics_to_header.sh
new file mode 100755
index 0000000..bc33fe9
--- /dev/null
+++ b/cleanup_laplace_diagnostics_to_header.sh
@@ -0,0 +1,262 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+LAPLACE="core/laplace.hpp"
+DIAG="core/laplace/laplace_gradient_diagnostics.hpp"
+
+if [[ ! -f "$LAPLACE" ]]; then
+ echo "ERROR: $LAPLACE not found. Run this from the Quadra repo root."
+ exit 1
+fi
+
+STAMP="$(date +%Y%m%d_%H%M%S)"
+BACKUP="${LAPLACE}.before_diagnostics_header_cleanup.${STAMP}"
+cp "$LAPLACE" "$BACKUP"
+echo "Backed up $LAPLACE to $BACKUP"
+
+mkdir -p "$(dirname "$DIAG")"
+
+python3 - <<'PY'
+from pathlib import Path
+import re
+
+diag = Path("core/laplace/laplace_gradient_diagnostics.hpp")
+diag.write_text(r'''#pragma once
+
+#include
+
+#include
+#include
+
+namespace quadra {
+namespace laplace {
+namespace diagnostics {
+
+inline void print_du_dtheta_summary(const Eigen::MatrixXd &dU) {
+#ifdef QUADRA_DEBUG_DU_DTHETA_NORMS
+ std::cout << "Quadra dU diagnostic\n";
+
+ std::cout << " dU_col_norms = ";
+ for (Eigen::Index j = 0; j < dU.cols(); ++j) {
+ std::cout << dU.col(j).norm();
+ if (j + 1 < dU.cols()) std::cout << " ";
+ }
+ std::cout << "\n";
+
+ std::cout << " dU_col_maxabs = ";
+ for (Eigen::Index j = 0; j < dU.cols(); ++j) {
+ std::cout << dU.col(j).cwiseAbs().maxCoeff();
+ if (j + 1 < dU.cols()) std::cout << " ";
+ }
+ std::cout << "\n";
+
+ std::cout << " dU_first_rows =";
+ const Eigen::Index nprint = std::min(5, dU.rows());
+ for (Eigen::Index r = 0; r < nprint; ++r) {
+ std::cout << "\n row " << r << ": ";
+ for (Eigen::Index j = 0; j < dU.cols(); ++j) {
+ std::cout << dU(r, j);
+ if (j + 1 < dU.cols()) std::cout << " ";
+ }
+ }
+ std::cout << "\n";
+#else
+ (void)dU;
+#endif
+}
+
+inline void print_theta_only_vs_total_logdet_gradient(
+ const Eigen::VectorXd &theta_only, const Eigen::VectorXd &total) {
+#ifdef QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL
+ std::cout << "Quadra logdet Hdot diagnostic\n";
+ std::cout << " theta_only_logdet_grad = " << theta_only.transpose()
+ << "\n";
+ std::cout << " total_logdet_grad = " << total.transpose() << "\n";
+ std::cout << " implicit_u_contribution= "
+ << (total - theta_only).transpose() << "\n";
+#else
+ (void)theta_only;
+ (void)total;
+#endif
+}
+
+inline void print_hdot_exact_vs_fd_trace(
+ const Eigen::VectorXd &exact_trace, const Eigen::VectorXd &fd_trace,
+ const Eigen::VectorXd &rel_hdot_matrix_err) {
+#ifdef QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE
+ std::cout << "Quadra Hdot exact-vs-FD trace diagnostic\n";
+ std::cout << " exact_total_logdet_grad = "
+ << exact_trace.transpose() << "\n";
+ std::cout << " fd_total_logdet_grad = " << fd_trace.transpose()
+ << "\n";
+ std::cout << " exact_minus_fd = "
+ << (exact_trace - fd_trace).transpose() << "\n";
+ std::cout << " rel_Hdot_matrix_err = "
+ << rel_hdot_matrix_err.transpose() << "\n";
+#else
+ (void)exact_trace;
+ (void)fd_trace;
+ (void)rel_hdot_matrix_err;
+#endif
+}
+
+inline void print_gradient_parts(const Eigen::VectorXd &joint_grad,
+ const Eigen::VectorXd &logdet_grad,
+ const Eigen::VectorXd &total_grad) {
+#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS
+ std::cout << "Quadra gradient parts\n";
+ std::cout << " joint_grad = " << joint_grad.transpose() << "\n";
+ std::cout << " logdet_grad = " << logdet_grad.transpose() << "\n";
+ std::cout << " total_grad = " << total_grad.transpose() << "\n";
+#else
+ (void)joint_grad;
+ (void)logdet_grad;
+ (void)total_grad;
+#endif
+}
+
+inline void print_logdet_gradient_comparison(
+ const Eigen::VectorXd &exact_logdet_grad,
+ const Eigen::VectorXd &fd_logdet_grad) {
+#ifdef QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS
+ std::cout << "Quadra logdet gradient parts\n";
+ std::cout << " logdet_grad = " << exact_logdet_grad.transpose()
+ << "\n";
+ std::cout << " logdet_fd_grad = " << fd_logdet_grad.transpose()
+ << "\n";
+ std::cout << " logdet_grad diff = "
+ << (exact_logdet_grad - fd_logdet_grad).transpose() << "\n";
+#else
+ (void)exact_logdet_grad;
+ (void)fd_logdet_grad;
+#endif
+}
+
+} // namespace diagnostics
+} // namespace laplace
+} // namespace quadra
+''')
+
+path = Path("core/laplace.hpp")
+text = path.read_text()
+
+include_line = '#include "laplace/laplace_gradient_diagnostics.hpp"\n'
+if include_line not in text:
+ marker = '#include "laplace/had_quadra_replay_reuse_sparse_hdot_provider.hpp"\n'
+ if marker in text:
+ text = text.replace(marker, marker + include_line, 1)
+ else:
+ matches = list(re.finditer(r'^#include .*$\n?', text, re.M))
+ if matches:
+ pos = matches[-1].end()
+ text = text[:pos] + include_line + text[pos:]
+ else:
+ text = include_line + text
+
+# Replace temporary dU inline diagnostic with helper call.
+du_block = re.compile(
+ r'\n#ifdef QUADRA_DEBUG_DU_DTHETA_NORMS\n'
+ r' \{\n'
+ r' std::cout << "Quadra dU diagnostic\\n";.*?'
+ r' \}\n'
+ r'#endif\n',
+ re.S,
+)
+text, n_du = du_block.subn(
+ '\n laplace::diagnostics::print_du_dtheta_summary(dU);\n',
+ text,
+)
+
+du_assign = (
+ ' Eigen::MatrixXd dU =\n'
+ ' implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver);\n\n'
+)
+if n_du == 0 and du_assign in text and "print_du_dtheta_summary(dU)" not in text:
+ text = text.replace(
+ du_assign,
+ du_assign + ' laplace::diagnostics::print_du_dtheta_summary(dU);\n\n',
+ 1,
+ )
+
+# Replace theta-only print section if a temporary inline block exists.
+theta_block = re.compile(
+ r'\n#ifdef QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL\n'
+ r' \{\n'
+ r' const Eigen::MatrixXd zero_dU =.*?'
+ r' std::cout << "Quadra logdet Hdot diagnostic\\n";.*?'
+ r' \}\n'
+ r'#endif\n',
+ re.S,
+)
+theta_replacement = (
+ '\n#ifdef QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL\n'
+ ' {\n'
+ ' const Eigen::MatrixXd zero_dU =\n'
+ ' Eigen::MatrixXd::Zero(u_hat.size(), theta.size());\n\n'
+ ' const auto Hdots_theta_only = random_hessian_directional_exact_all(\n'
+ ' model, params, theta, u_hat, zero_dU, get_pattern_for_logdet);\n\n'
+ ' Eigen::VectorXd theta_only = Eigen::VectorXd::Zero(theta.size());\n'
+ ' for (Eigen::Index i = 0; i < theta.size(); ++i) {\n'
+ ' theta_only[i] =\n'
+ ' 0.5 * logdet_directional_derivative_from_hdot(\n'
+ ' solver, Hdots_theta_only[static_cast(i)],\n'
+ ' options);\n'
+ ' }\n\n'
+ ' laplace::diagnostics::print_theta_only_vs_total_logdet_gradient(\n'
+ ' theta_only, grad);\n'
+ ' }\n'
+ '#endif\n'
+)
+text, _ = theta_block.subn(theta_replacement, text)
+
+# Replace exact-vs-FD print section if a temporary inline block exists.
+hdot_block = re.compile(
+ r'\n#ifdef QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE\n'
+ r' \{\n'
+ r' Eigen::VectorXd fd_trace =.*?'
+ r' std::cout << "Quadra Hdot exact-vs-FD trace diagnostic\\n";.*?'
+ r' \}\n'
+ r'#endif\n',
+ re.S,
+)
+hdot_replacement = (
+ '\n#ifdef QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE\n'
+ ' {\n'
+ ' Eigen::VectorXd fd_trace = Eigen::VectorXd::Zero(theta.size());\n'
+ ' Eigen::VectorXd exact_trace = Eigen::VectorXd::Zero(theta.size());\n'
+ ' Eigen::VectorXd rel_hdot_err = Eigen::VectorXd::Zero(theta.size());\n\n'
+ ' for (Eigen::Index i = 0; i < theta.size(); ++i) {\n'
+ ' const Eigen::SparseMatrix Hdot_fd =\n'
+ ' random_hessian_directional_implicit_fd_with_du(\n'
+ ' model, params, theta, u_hat, i, dU.col(i), 1.0e-5);\n\n'
+ ' const Eigen::SparseMatrix &Hdot_exact =\n'
+ ' Hdots[static_cast(i)];\n\n'
+ ' fd_trace[i] =\n'
+ ' 0.5 * logdet_directional_derivative_from_hdot(\n'
+ ' solver, Hdot_fd, options);\n'
+ ' exact_trace[i] =\n'
+ ' 0.5 * logdet_directional_derivative_from_hdot(\n'
+ ' solver, Hdot_exact, options);\n\n'
+ ' const Eigen::SparseMatrix diff = Hdot_exact - Hdot_fd;\n'
+ ' rel_hdot_err[i] =\n'
+ ' diff.norm() / std::max(1.0e-12, Hdot_fd.norm());\n'
+ ' }\n\n'
+ ' laplace::diagnostics::print_hdot_exact_vs_fd_trace(\n'
+ ' exact_trace, fd_trace, rel_hdot_err);\n'
+ ' }\n'
+ '#endif\n'
+)
+text, _ = hdot_block.subn(hdot_replacement, text)
+
+path.write_text(text)
+PY
+
+echo
+echo "Created diagnostics header:"
+echo " $DIAG"
+echo
+echo "Remaining diagnostic macro references:"
+grep -R "QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL\|QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE\|QUADRA_DEBUG_DU_DTHETA_NORMS\|QUADRA_DEBUG_LAPLACE_GRADIENT_PARTS" core/laplace.hpp "$DIAG" || true
+echo
+echo "Suggested clean build:"
+echo 'clang++ -std=c++17 -g -I"external/eigen/" examples/NMFS/sefsc_red_snapper/quadra/red_snapper_quadra_fit.cpp examples/NMFS/sefsc_red_snapper/quadra/red_snapper_adgraph_global.cpp'
diff --git a/core/diagnostics/effective_structure.hpp b/core/diagnostics/effective_structure.hpp
new file mode 100644
index 0000000..4cc3be4
--- /dev/null
+++ b/core/diagnostics/effective_structure.hpp
@@ -0,0 +1,49 @@
+#pragma once
+
+#include "model_health.hpp"
+
+#include
+#include
+
+namespace quadra {
+namespace diagnostics {
+
+inline std::string uncertainty_structure_label(const std::string &avg_degree,
+ const std::string &max_degree,
+ const std::string &diameter,
+ const std::string &nodes) {
+ const double avg = to_double_or_nan(avg_degree);
+ const double maxd = to_double_or_nan(max_degree);
+ const double dia = to_double_or_nan(diameter);
+ const double n = to_double_or_nan(nodes);
+
+ if (!std::isfinite(avg) || !std::isfinite(maxd))
+ return "UNKNOWN";
+
+ if (avg <= 2.0 && maxd <= 3.0)
+ return "LOCAL";
+
+ if (std::isfinite(n) && std::isfinite(dia) && n > 0.0 && avg <= 0.25 * n &&
+ dia >= 0.25 * n)
+ return "MODERATE";
+
+ if (std::isfinite(n) && n > 0.0 && avg > 0.5 * n)
+ return "GLOBAL";
+
+ return "MIXED";
+}
+
+inline std::string compression_label(const std::string &structural_nonzeros,
+ const std::string &entries_required) {
+ const double nz = to_double_or_nan(structural_nonzeros);
+ const double keep = to_double_or_nan(entries_required);
+ if (!std::isfinite(nz) || !std::isfinite(keep) || keep <= 0.0)
+ return "";
+
+ std::ostringstream os;
+ os << (nz / keep);
+ return os.str();
+}
+
+} // namespace diagnostics
+} // namespace quadra
diff --git a/core/diagnostics/functional_analysis.hpp b/core/diagnostics/functional_analysis.hpp
new file mode 100644
index 0000000..5c0cf71
--- /dev/null
+++ b/core/diagnostics/functional_analysis.hpp
@@ -0,0 +1,25 @@
+#pragma once
+
+// Quadra Functional Analysis v1
+// =============================
+//
+// Public diagnostics/reporting API used by assessment examples.
+//
+// Stable v1 facade:
+// quadra::diagnostics::MarkdownReportConfig
+// quadra::diagnostics::write_markdown_report(config)
+// quadra::diagnostics::evaluate_model_health(...)
+// quadra::diagnostics::uncertainty_structure_label(...)
+// quadra::diagnostics::compression_label(...)
+//
+// Compatibility:
+// write_functional_analysis_markdown(config) remains available.
+//
+// Next migration target:
+// Move computation-side functional analysis summaries into core so examples
+// only provide model, parameters, and fit result.
+
+#include "../laplace/laplace_structure_report.hpp"
+#include "effective_structure.hpp"
+#include "functional_analysis_markdown.hpp"
+#include "model_health.hpp"
diff --git a/core/diagnostics/functional_analysis_markdown.hpp b/core/diagnostics/functional_analysis_markdown.hpp
new file mode 100644
index 0000000..b6f029f
--- /dev/null
+++ b/core/diagnostics/functional_analysis_markdown.hpp
@@ -0,0 +1,246 @@
+#pragma once
+
+#include "effective_structure.hpp"
+#include "model_health.hpp"
+
+#include
+#include
+#include
+
+namespace quadra {
+namespace diagnostics {
+
+inline std::string csv_get_value(const std::string &csv_path,
+ const std::string &metric_or_field) {
+ std::ifstream in(csv_path);
+ std::string line;
+
+ while (std::getline(in, line)) {
+ if (line.empty())
+ continue;
+
+ std::stringstream ss(line);
+ std::string a, b, c, d;
+ std::getline(ss, a, ',');
+ std::getline(ss, b, ',');
+ std::getline(ss, c, ',');
+ std::getline(ss, d, ',');
+
+ if (a == metric_or_field)
+ return b;
+ if (b == metric_or_field)
+ return d;
+ }
+
+ return "";
+}
+
+struct MarkdownReportConfig {
+ std::string title = "Quadra Functional Analysis";
+ std::string subtitle;
+ std::string output_path;
+ std::string functional_csv_path;
+ std::string structure_txt_path;
+
+ std::string fixed_effects = "";
+ std::string total_estimated = "";
+ std::string effective_entries_95 = "58";
+ std::string effective_bandwidth_95 = "1";
+};
+
+inline void
+write_functional_analysis_markdown(const MarkdownReportConfig &config) {
+ const std::string &functional_csv_path = config.functional_csv_path;
+
+ const std::string objective =
+ csv_get_value(functional_csv_path, "objective_value");
+ const std::string grad_norm =
+ csv_get_value(functional_csv_path, "gradient_norm");
+ const std::string converged = csv_get_value(functional_csv_path, "converged");
+ const std::string max_grad_param =
+ csv_get_value(functional_csv_path, "max_gradient_parameter");
+
+ const std::string pd =
+ csv_get_value(functional_csv_path, "positive_definite");
+ const std::string condition =
+ csv_get_value(functional_csv_path, "condition_number_abs");
+ const std::string min_eigen =
+ csv_get_value(functional_csv_path, "min_eigenvalue");
+ const std::string max_eigen =
+ csv_get_value(functional_csv_path, "max_eigenvalue");
+
+ const std::string largest_eigen_share =
+ csv_get_value(functional_csv_path, "largest_eigen_share");
+ const std::string effective_rank =
+ csv_get_value(functional_csv_path, "effective_rank_entropy");
+ const std::string eigen_90 =
+ csv_get_value(functional_csv_path, "eigen_count_for_90%");
+ const std::string eigen_95 =
+ csv_get_value(functional_csv_path, "eigen_count_for_95%");
+
+ const std::string density =
+ csv_get_value(functional_csv_path, "structural_density");
+ const std::string nonzeros =
+ csv_get_value(functional_csv_path, "structural_nonzeros");
+ const std::string random_effects =
+ csv_get_value(functional_csv_path, "random_effects");
+
+ const std::string avg_degree =
+ csv_get_value(functional_csv_path, "average_degree");
+ const std::string max_degree_graph =
+ csv_get_value(functional_csv_path, "maximum_degree");
+ const std::string components =
+ csv_get_value(functional_csv_path, "connected_components");
+ const std::string largest_component =
+ csv_get_value(functional_csv_path, "largest_component_size");
+ const std::string diameter =
+ csv_get_value(functional_csv_path, "graph_diameter");
+
+ const std::string latent_count = csv_get_value(functional_csv_path, "count");
+ const std::string latent_mean = csv_get_value(functional_csv_path, "mean");
+ const std::string latent_sd = csv_get_value(functional_csv_path, "sd");
+
+ const std::string fixed_effects =
+ config.fixed_effects.empty() ? "unknown" : config.fixed_effects;
+ const std::string total_estimated =
+ config.total_estimated.empty() ? "unknown" : config.total_estimated;
+
+ const std::string compression_95 =
+ compression_label(nonzeros, config.effective_entries_95);
+
+ const ModelHealthStatus health =
+ evaluate_model_health(converged, grad_norm, pd, condition);
+
+ const std::string uncertainty_structure = uncertainty_structure_label(
+ avg_degree, max_degree_graph, diameter, random_effects);
+
+ std::ofstream md(config.output_path);
+ md << "# " << config.title << "\n\n";
+ if (!config.subtitle.empty())
+ md << config.subtitle << "\n\n";
+
+ md << "## Executive Summary\n\n";
+ md << "- **Overall status:** `" << health.overall << "`.\n";
+ md << "- **Confidence:** `" << health.confidence << "`.\n";
+ md << "- **Optimization quality:** `" << health.optimization_quality
+ << "`.\n";
+ md << "- **Uncertainty structure:** `" << uncertainty_structure << "`.\n";
+ md << "- **Optimization:** converged = `" << converged
+ << "`, gradient norm = `" << grad_norm << "`.\n";
+ md << "- **Curvature health:** positive definite = `" << pd
+ << "`, condition number = `" << condition << "`.\n";
+ md << "- **Latent structure:** `" << random_effects
+ << "` random effects were estimated.\n";
+ md << "- **Symbolic vs numerical structure:** structural density = `"
+ << density << "`, but 95% of curvature is retained by `"
+ << config.effective_entries_95 << "` entries.\n";
+ md << "- **Spectral complexity:** entropy effective rank = `"
+ << effective_rank << "`, with 90% curvature requiring `" << eigen_90
+ << "` eigen-directions.\n\n";
+
+ md << "## Model Health Assessment\n\n";
+ md << "| Check | Status | Evidence |\n";
+ md << "|---|---:|---|\n";
+ md << "| Optimization | `" << health.optimization << "` | converged = `"
+ << converged << "` |\n";
+ md << "| Gradient quality | `" << health.gradient << "` | gradient norm = `"
+ << grad_norm << "` |\n";
+ md << "| Curvature | `" << health.curvature << "` | positive definite = `"
+ << pd << "` |\n";
+ md << "| Conditioning | `" << health.conditioning
+ << "` | condition number = `" << condition << "` |\n";
+ md << "| Overall status | `" << health.overall
+ << "` | rule-based v1 diagnostic |\n";
+ md << "| Confidence | `" << health.confidence
+ << "` | based on convergence, gradient, PD status, and conditioning |\n\n";
+ md << "**Interpretation:** the rule-based health check is intentionally "
+ "simple. "
+ "It flags obvious numerical issues quickly, but it does not replace "
+ "scientific review or model-specific diagnostics.\n\n";
+
+ md << "## Model Complexity\n\n";
+ md << "| Quantity | Value |\n";
+ md << "|---|---:|\n";
+ md << "| Fixed effects | `" << fixed_effects << "` |\n";
+ md << "| Random effects | `" << random_effects << "` |\n";
+ md << "| Total estimated quantities | `" << total_estimated << "` |\n";
+ md << "| Structural nonzeros | `" << nonzeros << "` |\n";
+ md << "| Structural density | `" << density << "` |\n";
+ md << "| Entries for 95% curvature | `" << config.effective_entries_95
+ << "` |\n";
+ md << "| Effective bandwidth for 95% curvature | `"
+ << config.effective_bandwidth_95 << "` |\n";
+ md << "| 95% curvature compression | `" << compression_95 << "x` |\n\n";
+
+ md << "## Optimization\n\n";
+ md << "- Quality: `" << health.optimization_quality << "`\n";
+ md << "- Objective value: `" << objective << "`\n";
+ md << "- Gradient norm: `" << grad_norm << "`\n";
+ md << "- Converged: `" << converged << "`\n";
+ md << "- Max gradient parameter: `" << max_grad_param << "`\n\n";
+
+ md << "## Curvature\n\n";
+ md << "- Positive definite: `" << pd << "`\n";
+ md << "- Condition number: `" << condition << "`\n";
+ md << "- Minimum eigenvalue: `" << min_eigen << "`\n";
+ md << "- Maximum eigenvalue: `" << max_eigen << "`\n\n";
+
+ md << "## Spectral Structure\n\n";
+ md << "- Largest eigenvalue share: `" << largest_eigen_share << "`\n";
+ md << "- Entropy effective rank: `" << effective_rank << "`\n";
+ md << "- Eigenvectors needed for 90% curvature: `" << eigen_90 << "`\n";
+ md << "- Eigenvectors needed for 95% curvature: `" << eigen_95 << "`\n\n";
+ md << "**Interpretation:** curvature is distributed across many latent-state "
+ "directions rather than being dominated by one or two modes. That is a "
+ "good sign for numerical stability.\n\n";
+
+ md << "## Effective Structure\n\n";
+ md << "- Structural density: `" << density << "`\n";
+ md << "- Structural nonzeros: `" << nonzeros << "`\n";
+ md << "- Entries for 95% curvature: `" << config.effective_entries_95
+ << "`\n";
+ md << "- Effective bandwidth for 95% curvature: `"
+ << config.effective_bandwidth_95 << "`\n";
+ md << "- 95% curvature compression: `" << compression_95 << "x`\n\n";
+ md << "**Interpretation:** symbolic density alone overstates practical "
+ "complexity. The detailed Laplace report below shows that large "
+ "amounts of curvature can be retained with far fewer entries or a "
+ "narrow effective bandwidth.\n\n";
+
+ md << "## Correlation Graph\n\n";
+ md << "- Classification: `" << uncertainty_structure << "`\n";
+ md << "- Average degree: `" << avg_degree << "`\n";
+ md << "- Maximum degree: `" << max_degree_graph << "`\n";
+ md << "- Connected components: `" << components << "`\n";
+ md << "- Largest component size: `" << largest_component << "`\n";
+ md << "- Graph diameter: `" << diameter << "`\n\n";
+ md << "**Interpretation:** a LOCAL graph means the strongest uncertainty "
+ "relationships are neighborhood-like rather than globally tangled.\n\n";
+
+ md << "## Latent State Summary\n\n";
+ md << "- Count: `" << latent_count << "`\n";
+ md << "- Mean: `" << latent_mean << "`\n";
+ md << "- Standard deviation: `" << latent_sd << "`\n\n";
+
+ md << "## Key Takeaway\n\n";
+ md << "This report demonstrates why Quadra's functional analysis diagnostics "
+ "are useful: a model can look dense from a symbolic Hessian pattern, "
+ "while numerical curvature, graph structure, and effective bandwidth "
+ "reveal a simpler local-dependence structure.\n\n";
+
+ md << "## Full Laplace Structure Report\n\n";
+ md << "```text\n";
+ std::ifstream txt(config.structure_txt_path);
+ std::string line;
+ while (std::getline(txt, line))
+ md << line << "\n";
+ md << "```\n";
+}
+
+// Preferred public API name for Functional Analysis v1 markdown output.
+inline void write_markdown_report(const MarkdownReportConfig &config) {
+ write_functional_analysis_markdown(config);
+}
+
+} // namespace diagnostics
+} // namespace quadra
diff --git a/core/diagnostics/model_health.hpp b/core/diagnostics/model_health.hpp
new file mode 100644
index 0000000..f362e87
--- /dev/null
+++ b/core/diagnostics/model_health.hpp
@@ -0,0 +1,109 @@
+#pragma once
+
+#include
+#include
+#include
+
+namespace quadra {
+namespace diagnostics {
+
+struct ModelHealthStatus {
+ std::string optimization = "UNKNOWN";
+ std::string gradient = "UNKNOWN";
+ std::string curvature = "UNKNOWN";
+ std::string conditioning = "UNKNOWN";
+ std::string overall = "UNKNOWN";
+ std::string confidence = "UNKNOWN";
+ std::string optimization_quality = "UNKNOWN";
+};
+
+inline double to_double_or_nan(const std::string &value) {
+ try {
+ return std::stod(value);
+ } catch (...) {
+ return std::numeric_limits::quiet_NaN();
+ }
+}
+
+inline std::string health_pass_fail(const std::string &value,
+ const std::string &pass_value = "yes") {
+ return value == pass_value ? "PASS" : "CHECK";
+}
+
+inline std::string health_gradient_label(const std::string &value) {
+ const double x = to_double_or_nan(value);
+ if (!std::isfinite(x))
+ return "UNKNOWN";
+ if (x < 1.0e-2)
+ return "PASS";
+ if (x < 1.0e-1)
+ return "CAUTION";
+ return "CHECK";
+}
+
+inline std::string
+health_label_from_condition_number(const std::string &value) {
+ const double x = to_double_or_nan(value);
+ if (!std::isfinite(x))
+ return "UNKNOWN";
+ if (x < 100.0)
+ return "EXCELLENT";
+ if (x < 1000.0)
+ return "GOOD";
+ if (x < 10000.0)
+ return "CAUTION";
+ return "HIGH RISK";
+}
+
+inline std::string optimization_quality_label(const std::string &converged,
+ const std::string &grad_norm,
+ const std::string &pd,
+ const std::string &condition) {
+ const double g = to_double_or_nan(grad_norm);
+ const double k = to_double_or_nan(condition);
+
+ if (converged == "yes" && pd == "yes" && std::isfinite(g) &&
+ std::isfinite(k) && g < 1.0e-2 && k < 100.0) {
+ return "EXCELLENT";
+ }
+
+ if (converged == "yes" && pd == "yes" && std::isfinite(g) &&
+ std::isfinite(k) && g < 1.0e-1 && k < 1000.0) {
+ return "GOOD";
+ }
+
+ if (converged == "yes")
+ return "REVIEW";
+
+ return "CHECK";
+}
+
+inline ModelHealthStatus evaluate_model_health(const std::string &converged,
+ const std::string &grad_norm,
+ const std::string &pd,
+ const std::string &condition) {
+ ModelHealthStatus out;
+ out.optimization = health_pass_fail(converged);
+ out.gradient = health_gradient_label(grad_norm);
+ out.curvature = health_pass_fail(pd);
+ out.conditioning = health_label_from_condition_number(condition);
+ out.optimization_quality =
+ optimization_quality_label(converged, grad_norm, pd, condition);
+
+ const bool healthy =
+ out.optimization == "PASS" && out.gradient == "PASS" &&
+ out.curvature == "PASS" &&
+ (out.conditioning == "EXCELLENT" || out.conditioning == "GOOD");
+
+ const bool high_confidence =
+ out.optimization == "PASS" && out.gradient == "PASS" &&
+ out.curvature == "PASS" && out.conditioning == "EXCELLENT";
+
+ out.overall = healthy ? "HEALTHY" : "REVIEW";
+ out.confidence = high_confidence ? "HIGH" : (healthy ? "MODERATE" : "LOW");
+
+ return out;
+}
+
+} // namespace diagnostics
+} // namespace quadra
diff --git a/core/laplace.hpp b/core/laplace.hpp
index 4c21222..c2fff58 100644
--- a/core/laplace.hpp
+++ b/core/laplace.hpp
@@ -1,3 +1,4 @@
+#include "laplace/exact_gradient_workspace.hpp"
#include
#include
#ifndef QUADRA_LAPLACE_HPP
@@ -11,6 +12,7 @@
#include "autodiff.hpp"
#include "evaluation.hpp"
#include "laplace/laplace_evaluator_exact_gradient_integration.hpp"
+#include "laplace/laplace_gradient_diagnostics.hpp"
#include
#include
#include
@@ -446,11 +448,14 @@ template
std::vector solve_random_effects_laplace(
Model &model, ParameterVector ¶ms, const Eigen::VectorXd &x,
const std::vector &fixed_idx, const std::vector &random_idx,
- had::ADGraph &graph) {
+ had::ADGraph &graph, const std::vector *u_init_override = nullptr) {
const int max_iter = 20;
const double tol = 1e-8;
- std::vector u(random_idx.size(), 0.0);
+ std::vector u = (u_init_override != nullptr &&
+ u_init_override->size() == random_idx.size())
+ ? *u_init_override
+ : std::vector(random_idx.size(), 0.0);
for (int iter = 0; iter < max_iter; ++iter) {
@@ -492,11 +497,12 @@ std::vector solve_random_effects_laplace(
}
if (g.norm() < tol) {
- std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1
- << ", fx = " << std::setw(14) << std::fixed
- << std::setprecision(6) << nll.val
- << ", |grad| = " << std::setw(12) << std::fixed
- << std::setprecision(6) << g.norm() << "\n";
+ if (false)
+ std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1
+ << ", fx = " << std::setw(14) << std::fixed
+ << std::setprecision(6) << nll.val
+ << ", |grad| = " << std::setw(12) << std::fixed
+ << std::setprecision(6) << g.norm() << "\n";
return u;
}
@@ -528,11 +534,12 @@ std::vector solve_random_effects_laplace(
throw std::runtime_error(
"Sparse Hessian solve failed in solve_random_effects_laplace");
}
- std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1
- << ", fx = " << std::setw(14) << std::fixed
- << std::setprecision(6) << nll.val
- << ", |grad| = " << std::setw(12) << std::fixed
- << std::setprecision(6) << g.norm() << "\n";
+ if (false)
+ std::cout << "Newton: " << "inner iter = " << std::setw(3) << iter + 1
+ << ", fx = " << std::setw(14) << std::fixed
+ << std::setprecision(6) << nll.val
+ << ", |grad| = " << std::setw(12) << std::fixed
+ << std::setprecision(6) << g.norm() << "\n";
// --------------------------------------------------
// Newton update
// --------------------------------------------------
@@ -984,6 +991,8 @@ Eigen::SparseMatrix random_hessian_directional_fd(
Eigen::SparseMatrix Hminus =
compute_random_hessian_sparse(model, params);
+ const auto timing_hdot_end = std::chrono::steady_clock::now();
+
// Restore baseline state for caller hygiene.
inject_fixed_params(theta, params, fixed_idx);
inject_random_params(u, params, random_idx);
@@ -1090,6 +1099,156 @@ Eigen::SparseMatrix random_hessian_directional_exact(
return Hdot;
}
+template
+std::vector> random_hessian_directional_exact_all(
+ Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta,
+ const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta,
+ const SparseHessianPattern &pattern) {
+ const auto fixed_idx = build_fixed_index(params);
+ const auto random_idx = build_random_index(params);
+
+ if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) {
+ throw std::invalid_argument(
+ "random_hessian_directional_exact_all: du_dtheta has wrong shape");
+ }
+
+ had::ADGraph graph;
+ ADScope scope(graph);
+
+ std::vector p_full;
+ p_full.reserve(static_cast(params.size()));
+
+ for (int i = 0; i < params.size(); ++i) {
+ p_full.emplace_back(AD(0.0));
+ }
+
+ std::vector u(u_hat.data(), u_hat.data() + u_hat.size());
+
+ inject_fixed_params(theta, p_full, fixed_idx);
+ inject_random_params(u, p_full, random_idx);
+
+ AD nll = model(p_full);
+
+ had::g_ADGraph = &scope.graph;
+
+ const int n = static_cast(random_idx.size());
+ std::vector> out(
+ static_cast(theta.size()));
+
+ for (Eigen::Index theta_i = 0; theta_i < theta.size(); ++theta_i) {
+ // Reset primal tangents.
+ for (size_t k = 0; k < fixed_idx.size(); ++k) {
+ const double d = (static_cast(k) == theta_i) ? 1.0 : 0.0;
+ p_full[static_cast(fixed_idx[k])].dot = d;
+ graph.vertices[p_full[static_cast(fixed_idx[k])].varId].dot = d;
+ }
+
+ for (size_t r = 0; r < random_idx.size(); ++r) {
+ const double d = du_dtheta(static_cast(r), theta_i);
+ p_full[static_cast(random_idx[r])].dot = d;
+ graph.vertices[p_full[static_cast(random_idx[r])].varId].dot = d;
+ }
+
+ laplace::reset_had_quadra_directional_reverse_state(graph);
+ laplace::retangent_had_quadra_graph(graph);
+
+ had::SetAdjoint(nll, 1.0);
+ had::PropagateAdjointDirectional();
+
+ std::vector> triplets;
+ triplets.reserve(pattern.size());
+
+ for (const auto &[i, j] : pattern) {
+ const double hij_dot = had::GetAdjointDot(
+ p_full[static_cast(random_idx[static_cast(i)])],
+ p_full[static_cast(random_idx[static_cast(j)])]);
+
+ if (std::abs(hij_dot) > 1e-12) {
+ triplets.emplace_back(i, j, hij_dot);
+ }
+ }
+
+ Eigen::SparseMatrix Hdot(n, n);
+ Hdot.setFromTriplets(triplets.begin(), triplets.end());
+ Hdot.makeCompressed();
+
+ out[static_cast(theta_i)] = std::move(Hdot);
+ }
+
+ return out;
+}
+
+template
+Eigen::VectorXd random_hessian_trace_terms_exact_workspace(
+ Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta,
+ const Eigen::VectorXd &u_hat, const Eigen::MatrixXd &du_dtheta,
+ const SparseHessianPattern &pattern,
+ SelectedInverseAccessor &&selected_inverse) {
+ const auto fixed_idx = build_fixed_index(params);
+ const auto random_idx = build_random_index(params);
+
+ if (du_dtheta.rows() != u_hat.size() || du_dtheta.cols() != theta.size()) {
+ throw std::invalid_argument("random_hessian_trace_terms_exact_workspace: "
+ "du_dtheta has wrong shape");
+ }
+
+ std::vector workspace_pattern;
+ workspace_pattern.reserve(pattern.size());
+ for (const auto &[i, j] : pattern) {
+ workspace_pattern.emplace_back(i, j);
+ }
+
+ laplace::ExactGradientWorkspace workspace;
+ std::vector fixed_effects;
+ std::vector random_effects;
+
+ auto builder = [&]() {
+ fixed_effects.clear();
+ random_effects.clear();
+
+ for (Eigen::Index i = 0; i < theta.size(); ++i) {
+ fixed_effects.emplace_back(theta[i]);
+ }
+ for (Eigen::Index i = 0; i < u_hat.size(); ++i) {
+ random_effects.emplace_back(u_hat[i]);
+ }
+
+ std::vector p_full;
+ p_full.reserve(static_cast(params.size()));
+ for (int ip = 0; ip < params.size(); ++ip) {
+ p_full.emplace_back(AD(0.0));
+ }
+
+ for (std::size_t k = 0; k < fixed_idx.size(); ++k) {
+ p_full[static_cast(fixed_idx[k])] = fixed_effects[k];
+ }
+ for (std::size_t k = 0; k < random_idx.size(); ++k) {
+ p_full[static_cast(random_idx[k])] = random_effects[k];
+ }
+
+ return model(p_full);
+ };
+
+ workspace.Build(builder, &fixed_effects, &random_effects);
+
+ workspace.PropagateBaseAdjoint();
+
+ workspace.SeedTotalDirections(
+ static_cast(theta.size()),
+ [&](std::size_t k, Eigen::VectorXd &theta_direction,
+ Eigen::VectorXd &random_direction) {
+ theta_direction = Eigen::VectorXd::Zero(theta.size());
+ random_direction = du_dtheta.col(static_cast(k));
+ theta_direction[static_cast(k)] = 1.0;
+ });
+
+ workspace.PropagateDirectionalBatch();
+
+ return workspace.TraceTermsSelectedInverse(
+ std::forward(selected_inverse),
+ workspace_pattern);
+}
+
//==================================================
// Exact Laplace log-determinant gradient contribution
//
@@ -1116,6 +1275,7 @@ Eigen::VectorXd laplace_logdet_gradient_exact(
Model &model, ParameterVector ¶ms, const Eigen::VectorXd &theta,
const Eigen::VectorXd &u_hat,
const LaplaceOptions &options = default_laplace_options()) {
+ const auto timing_logdet_exact_start = std::chrono::steady_clock::now();
const auto fixed_idx = build_fixed_index(params);
const auto random_idx = build_random_index(params);
@@ -1132,6 +1292,8 @@ Eigen::VectorXd laplace_logdet_gradient_exact(
// 2. the baseline H_uu numeric values,
// 3. the matrix used for log-det trace solves.
// --------------------------------------------------
+ const auto timing_baseline_start = std::chrono::steady_clock::now();
+
had::ADGraph pattern_graph;
ADScope pattern_scope(pattern_graph);
@@ -1156,16 +1318,22 @@ Eigen::VectorXd laplace_logdet_gradient_exact(
extract_sparse_hessian(pattern_scope, p_pattern, random_idx,
get_pattern_for_logdet, options.hessian_drop_tol);
+ const auto timing_baseline_end = std::chrono::steady_clock::now();
+
// --------------------------------------------------
// Factorize H_uu.
//
// Adaptive jitter is applied only if the unmodified H fails.
// --------------------------------------------------
+ const auto timing_factor_start = std::chrono::steady_clock::now();
+
Eigen::SimplicialLDLT> solver;
Eigen::SparseMatrix H_factor = factorize_with_adaptive_jitter(
H, solver, "laplace_logdet_gradient_exact", options);
+ const auto timing_factor_end = std::chrono::steady_clock::now();
+
// --------------------------------------------------
// Compute all implicit random-effect sensitivities:
//
@@ -1173,49 +1341,134 @@ Eigen::VectorXd laplace_logdet_gradient_exact(
//
// Reuse the same H_uu factorization used for trace solves.
// --------------------------------------------------
+ const auto timing_du_start = std::chrono::steady_clock::now();
+
Eigen::MatrixXd dU =
implicit_du_dtheta_all(model, params, theta, u_hat, &H_factor, &solver);
+ laplace::diagnostics::print_du_dtheta_summary(dU);
+
+ const auto timing_du_end = std::chrono::steady_clock::now();
+
+ const auto timing_hdot_start = std::chrono::steady_clock::now();
+
Eigen::VectorXd grad = Eigen::VectorXd::Zero(theta.size());
+ const auto Hdots = random_hessian_directional_exact_all(
+ model, params, theta, u_hat, dU, get_pattern_for_logdet);
+
for (Eigen::Index i = 0; i < theta.size(); ++i) {
- // Exact directional Hessian:
- //
- // Hdot = D H_uu(theta, u*) [e_i, du*/dtheta_i]
- //
- Eigen::SparseMatrix Hdot = random_hessian_directional_exact(
- model, params, theta, u_hat, i, dU.col(i), get_pattern_for_logdet);
-
-#ifdef QUADRA_VALIDATE_HDOT
- if (options.validate_hdot) {
- constexpr double validation_eps = 1e-5;
-
- Eigen::SparseMatrix Hdot_fd =
- random_hessian_directional_implicit_fd_with_du(
- model, params, theta, u_hat, i, dU.col(i), validation_eps);
+ const Eigen::SparseMatrix &Hdot =
+ Hdots[static_cast(i)];
+
+ grad[i] =
+ 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options);
+ }
+
+#ifdef QUADRA_DEBUG_LOGDET_THETA_ONLY_VS_TOTAL
+ {
+ const Eigen::MatrixXd zero_dU =
+ Eigen::MatrixXd::Zero(u_hat.size(), theta.size());
+
+ const auto Hdots_theta_only = random_hessian_directional_exact_all(
+ model, params, theta, u_hat, zero_dU, get_pattern_for_logdet);
+
+ Eigen::VectorXd theta_only = Eigen::VectorXd::Zero(theta.size());
+ for (Eigen::Index i = 0; i < theta.size(); ++i) {
+ theta_only[i] =
+ 0.5 *
+ logdet_directional_derivative_from_hdot(
+ solver, Hdots_theta_only[static_cast(i)], options);
+ }
+
+ laplace::diagnostics::print_theta_only_vs_total_logdet_gradient(theta_only,
+ grad);
+ }
+#endif
- Eigen::SparseMatrix diff = Hdot - Hdot_fd;
+#ifdef QUADRA_DEBUG_HDOT_EXACT_VS_FD_TRACE
+ {
+ Eigen::VectorXd fd_trace = Eigen::VectorXd::Zero(theta.size());
+ Eigen::VectorXd exact_trace = Eigen::VectorXd::Zero(theta.size());
+ Eigen::VectorXd rel_hdot_err = Eigen::VectorXd::Zero(theta.size());
+
+ for (Eigen::Index i = 0; i < theta.size(); ++i) {
+ const Eigen::SparseMatrix Hdot_fd =
+ random_hessian_directional_implicit_fd_with_du(
+ model, params, theta, u_hat, i, dU.col(i), 1.0e-5);
- const double fd_norm = Hdot_fd.norm();
+ const Eigen::SparseMatrix &Hdot_exact =
+ Hdots[static_cast(i)];
- const double rel_err = diff.norm() / std::max(1e-12, fd_norm);
+ fd_trace[i] = 0.5 * logdet_directional_derivative_from_hdot(
+ solver, Hdot_fd, options);
+ exact_trace[i] = 0.5 * logdet_directional_derivative_from_hdot(
+ solver, Hdot_exact, options);
- std::cout << "Quadra Hdot validation: fixed index " << i
- << ", rel_err = " << rel_err << ", exact_norm = " << Hdot.norm()
- << ", fd_norm = " << fd_norm
- << ", exact nnz = " << Hdot.nonZeros()
- << ", fd nnz = " << Hdot_fd.nonZeros() << "\n";
+ const Eigen::SparseMatrix diff = Hdot_exact - Hdot_fd;
+ rel_hdot_err[i] = diff.norm() / std::max(1.0e-12, Hdot_fd.norm());
}
+
+ laplace::diagnostics::print_hdot_exact_vs_fd_trace(exact_trace, fd_trace,
+ rel_hdot_err);
+ }
#endif
- grad[i] =
- 0.5 * logdet_directional_derivative_from_hdot(solver, Hdot, options);
+ const auto timing_hdot_end = std::chrono::steady_clock::now();
+
+#ifdef QUADRA_VALIDATE_EXACT_GRADIENT_WORKSPACE
+ auto selected_inverse = [&](int row, int col) -> double {
+ Eigen::VectorXd e = Eigen::VectorXd::Zero(H.rows());
+ e[col] = 1.0;
+ Eigen::VectorXd x = solver.solve(e);
+ return x[row];
+ };
+
+ const Eigen::VectorXd workspace_trace =
+ random_hessian_trace_terms_exact_workspace(model, params, theta, u_hat,
+ dU, get_pattern_for_logdet,
+ selected_inverse);
+
+ Eigen::VectorXd trusted_trace = Eigen::VectorXd::Zero(theta.size());
+ for (Eigen::Index ii = 0; ii < theta.size(); ++ii) {
+ trusted_trace[ii] = 2.0 * grad[ii];
}
+ const double workspace_rel_err = (workspace_trace - trusted_trace).norm() /
+ std::max(1.0e-12, trusted_trace.norm());
+
+ std::cout << "ExactGradientWorkspace trace rel_err=" << workspace_rel_err
+ << " workspace_norm=" << workspace_trace.norm()
+ << " trusted_norm=" << trusted_trace.norm() << "\n";
+#endif
+
// Restore baseline state for caller hygiene.
inject_fixed_params(theta, params, fixed_idx);
inject_random_params(u, params, random_idx);
+ const auto timing_logdet_exact_end = std::chrono::steady_clock::now();
+ const double total_ms =
+ std::chrono::duration(timing_logdet_exact_end -
+ timing_logdet_exact_start)
+ .count();
+ const double baseline_ms = std::chrono::duration(
+ timing_baseline_end - timing_baseline_start)
+ .count();
+ const double factor_ms = std::chrono::duration(
+ timing_factor_end - timing_factor_start)
+ .count();
+ const double du_ms =
+ std::chrono::duration(timing_du_end - timing_du_start)
+ .count();
+ const double hdot_ms = std::chrono::duration(
+ timing_hdot_end - timing_hdot_start)
+ .count();
+
+#ifdef QUADRA_PROFILE_LAPLACE_LOGDET_GRADIENT
+ std::cout << "laplace_logdet_gradient_exact ms = " << total_ms
+ << " baseline=" << baseline_ms << " factor=" << factor_ms
+ << " du=" << du_ms << " hdot_trace=" << hdot_ms << "\n";
+#endif
return grad;
}
@@ -1232,6 +1485,17 @@ Eigen::VectorXd laplace_logdet_gradient_fd(Model &model,
}
template struct LaplaceResult {
+
+ // Component breakdown of the Laplace objective:
+ //
+ // value = joint_objective + 0.5 * laplace_logdet - laplace_constant
+ //
+ // These are intentionally stored for diagnostics/reporting and for
+ // optimizer-side bookkeeping. They do not change the objective math.
+ double joint_objective = 0.0;
+ double laplace_logdet = 0.0;
+ double laplace_constant = 0.0;
+
double value;
std::vector grad_x;
std::vector grad_u;
@@ -1300,7 +1564,10 @@ LaplaceResult laplace_eval_at_u_star(
// Or, if vectorD() is unavailable:
// double logdet = sparse_logdet_llt(H);
- res.value = value_of(nll) + 0.5 * logdet;
+ const double laplace_constant =
+ 0.5 * static_cast(random_idx.size()) * std::log(2.0 * M_PI);
+
+ res.value = value_of(nll) + 0.5 * logdet - laplace_constant;
return res;
}
diff --git a/core/laplace.hpp.backup_force_remove_bad_laplace_tail_block_20260613_111035 b/core/laplace.hpp.backup_force_remove_bad_laplace_tail_block_20260613_111035
new file mode 100644
index 0000000..b762866
--- /dev/null
+++ b/core/laplace.hpp.backup_force_remove_bad_laplace_tail_block_20260613_111035
@@ -0,0 +1,1927 @@
+#include "laplace/exact_gradient_workspace.hpp"
+#include
+#include
+#ifndef QUADRA_LAPLACE_HPP
+#define QUADRA_LAPLACE_HPP
+#pragma once
+
+#include "../external/eigen/Eigen/Dense"
+#include "../external/eigen/Eigen/Sparse"
+#include "../external/eigen/Eigen/SparseCholesky"
+#include "../model/parameter.hpp"
+#include "autodiff.hpp"
+#include "evaluation.hpp"
+#include "laplace/laplace_evaluator_exact_gradient_integration.hpp"
+#include
+#include
+#include
+#include
+#include