Skip to content
Copy as Markdown

Reporting & Verification (V&V)

Every BaseCalibration/BaseIdentification run produces the same underlying metrics — condition number, per-DOF/per-joint residuals, parameter uncertainty, held-out validation. This guide covers the four ways those metrics get surfaced, and when to reach for each one.

Artifact Call Audience
Terminal quality report automatic, after solve() you, right now, in the terminal
Self-contained HTML report solve(html_report=True) / export_html_report() you, or anyone you send the file to
Machine-readable verdict (JSON) verify() / export_verification_report() CI, scripts, anything that needs a pass/fail
Static two-run compare page figaroh.tools.compare_report.generate_compare_page() comparing two exported verdicts, offline

All four read from data solve() already computed — none of them re-run the calibration/identification, and none of them require network access or a backend.

Interpreting a verdict

Use Plan, Fit and Validate to establish input correctness, parameter scope and independent validation before interpreting these reports. Inspect computed and skipped checks: a passing verdict with unavailable validation metrics does not establish held-out accuracy. Keep solver termination, physical feasibility, prediction quality and export/reload parity as separate conclusions. The compare page's compatibility checks do not establish matching raw inputs, processing, objectives or absence of leakage; retain that provenance.

Stages, splits and revisions

Each run records what each step did (figaroh.tools.stages, #55/#63). The terminal report prints them on a Stages: line, and the HTML report shows a Stages and data section.

Each step gets its own verdict in verdict.stages: - the steps are data, fit, validation, physical and export; - each is pass, fail, fallback or not_run, or not_evaluated when the run recorded nothing for it; - the existing entries (numerical_execution, prediction, solver) are kept beside them.

A validation fallback means the metrics were computed on the training data. It is never held-out evidence, and prediction acceptance cannot pass on it.

Field Meaning
verdict.stage_records every step's status, reason and metrics with units
verdict.selected_stage the step whose parameters the result reports (fit; none if the fit failed)
verdict.splits which files, sessions and sample counts trained and validated the run, and whether validation was held out
provenance software.git_commit the working directory's commit (an example run: the examples repository)
provenance software.figaroh_revision the figaroh checkout's own commit and dirty state

Together, the two commits name the paired core/examples revisions. The exported JSON carries the records under stage_records and a schema_version. The run archive writes stages.json and adds each run's stage verdicts to index.jsonl.

Terminal quality reports

print_quality_report() runs automatically at the end of solve(); call it again any time after a successful solve to reprint it:

calibrator.solve()
calibrator.print_quality_report()   # same output, printed again

For calibration this prints convergence status, outlier count, condition number, per-DOF residual statistics (mean/std/RMSE/max/R²), held-out validation (if validation_data_file is configured), and any parameter pairs with |ρ| > 0.8. For identification it prints base-parameter count, condition number, RMSE, correlation, per-joint torque residuals, the top-5 worst-identified base parameters, and (if enabled) physical-consistency / reconstruction status.

Self-contained HTML reports

calibrator.solve(html_report=True)
# or, after the fact:
calibrator.export_html_report(output_path="results/calibration_report.html")

The identification side is the same shape:

identifier.solve(html_report=True)
identifier.export_html_report(output_path="results/identification_report.html")

Each report is a single self-contained HTML file — no external requests, no CDN dependency, inline CSS/JS only, light/dark theme aware — so it opens correctly straight from disk and is safe to attach to an email or a ticket. It contains:

  • Summary — the same headline numbers as the terminal report.
  • Insights — auto-flagged issues (ill-conditioning, poorly-identified parameters, weak validation improvement, no validation data configured).
  • Per-DOF / per-joint residuals and parameter uncertainty tables, each parameter's relative uncertainty rendered as a confidence-tier bar.
  • Validation — nominal vs. fitted vs. measured, when a genuinely separate held-out dataset was configured.
  • Before / after — an interactive overlay chart of the same nominal/fitted/measured series, with wheel-to-zoom and hover-to-inspect, so you don't have to read the numbers out of a table to see the fit improve. This needs no extra call — it's populated automatically whenever validation data is available.

Machine-readable verification

verify() checks the run's metrics against a set of pass/fail thresholds and returns a VerificationVerdict — this is the piece that turns a report (for a human to read) into something a script can branch on:

verdict = identifier.verify(scope="execution")
print(verdict.passed)          # bool
for check in verdict.checks:
    print(check.name, check.value, check.comparison, check.threshold, check.passed)

A bare library verify() defaults to prediction acceptance, which stays incomplete without a validation set and explicit limits. Routine example CLIs explicitly request numerical execution:

verdict = identifier.verify(scope="execution")
print(verdict.scope, verdict.status, verdict.stages)

This checks finite, nonempty fitted parameters, predictions, measurements and RMSE, matching effort dimensions, and whether explicitly requested validation loaded. Calibration additionally checks its reported optimization success and finite parameter/residual outputs. It does not certify data provenance, a full physical model or export/reload parity. Those stages remain not_evaluated.

There are no universal improvement, correlation, raw condition-number or calibration-error gates. These remain report diagnostics. A good nominal model may have little improvement; high correlation can coexist with biased torque; raw conditioning changes with parameter units and basis scaling.

For prediction acceptance, provide independent validation data and explicit application limits. For identification, supply an absolute maximum RMSE in Nm for every active validation joint:

profile = {
    f"validation_rmse:{joint}": {
        "threshold": allowed_error_nm[joint],
        "comparison": "max",
        "rationale": "measurement uncertainty and the application requirement",
    }
    for joint in active_joints
}
verdict = identifier.verify(scope="prediction", thresholds=profile)

Identification also exposes validation_abs_bias:<joint> and validation_peak_error:<joint> in Nm (or training_… for fallback). Add explicit limits where the application needs them; the per-joint RMSE limits remain required.

For calibration, use position_rmse_mm and, when rotational DOFs are measured, orientation_rmse_deg, with application-specific maximum limits. No replacement universal error value is supplied. Record units, the requirement behind each limit and the validation split before evaluating acceptance. Loading a separate file is necessary but does not prove experimental independence: split integrity, coverage and acquisition provenance remain the experiment owner's responsibility.

passed remains available for existing consumers and is true only if all required checks in the named scope pass. Additive status, scope, stages and applied policy fields make the meaning explicit:

  • pass: all required checks in scope succeeded.
  • fail: a required evaluated check failed, including computed NaN/Inf evidence.
  • not_evaluated: required evidence or a prediction profile is unavailable.

Missing checks are retained with their reason, rather than silently skipped. An empty threshold-only policy is not_evaluated, never PASS. A spec may set required=False for an advisory check; it is still reported. A failed explicitly requested validation load prevents execution acceptance. Training fallback can support diagnostics but cannot pass prediction acceptance. Physical and export acceptance require their own evidence; a scoped prediction PASS is not a full physical-model approval. Compare historical verdicts with care: their unscoped boolean used the earlier universal gates, not this policy.

Write the verdict to disk as JSON (numpy-safe, includes git commit / config hash / timestamp provenance):

path = identifier.export_verification_report(
    output_path=None,       # defaults to {output_dir}/identification_verification.json
    output_dir="results",
    thresholds=None,        # explicit limits, when evaluating prediction
    scope="execution",
)

The same call exists on BaseCalibration (writes {output_dir}/calibration_verification.json).

Wiring --verify into a CLI script

This is the pattern already used by the identification.py entry points in figaroh-examples:

verdict = identifier.verify(scope="execution")
identifier.export_verification_report(output_path=str(run_dir / "verdict.json"), scope="execution")

for check in verdict.checks:
    status = check.status.upper()
    print(f"  [{status}] {check.name}: {check.value} ({check.comparison} {check.threshold:.4g})")

if not verdict.passed:
    sys.exit(1)

Run it as python identification.py --verify. The scope must be printed with the verdict: zero means numerical execution passed by default, not that an independent prediction or physical model was accepted. Prediction-scoped incomplete evidence produces a nonzero exit.

Comparing two runs

figaroh.tools.compare_report.generate_compare_page() renders a static, self-contained HTML page that loads two export_verification_report() JSON files client-side (drag-and-drop or a file picker) and, once they pass a compatibility check, shows a per-metric diff table and an overlaid before/after series chart with a per-run visibility toggle:

from figaroh.tools.compare_report import generate_compare_page

generate_compare_page(output_path="results/compare.html")

Open results/compare.html in a browser and load two verdict JSON files — everything after that runs entirely client-side; there's no server, no network request, and nothing is uploaded anywhere.

The compatibility check runs before anything renders. Two verdicts are compared only if they agree on:

  • domain (calibration vs. identification — inferred from compat.dof_names vs. compat.active_joints)
  • the same DOF/joint names
  • the same decimate setting (identification only)
  • comparable sample counts (within ~20%)

On a mismatch the page blocks the comparison and explains why (e.g. "Different decimate setting: false vs. true") rather than silently overlaying two runs that aren't actually comparable — you can still force the comparison via an explicit "compare anyway" checkbox, with a persistent warning banner while forced.

This is deliberately a static, no-backend artifact, not a run library or dashboard — see the design rationale in docs/decisions/external-tool-comparisons.md Part C if you're curious why (short version: no validated need yet for a run history/backend, and comparing incompatible runs silently was judged actively dangerous).

CI integration example

A minimal build-gate recipe combining the pieces above:

identifier.solve(html_report=True)
verdict = identifier.verify()
identifier.export_verification_report(output_dir="results")

if not verdict.passed:
    failed = [c.name for c in verdict.checks if not c.passed]
    print(f"Verification failed: {', '.join(failed)}")
    sys.exit(1)

Point your CI at results/*_verification.json as a build artifact, and at results/*_report.html if you want a human-readable report attached to the same run.