Research: Retro-summary NFR investigation — evidence base + methodology (#2342)
Prior evidence assembled from the issue and the post-spec adversarial squad's measured leads. These are leads to verify, not conclusions.
Background (from #2342)
test_200_missions_under_5s(NFR-003:build_summaryover a 200-mission corpus < 5s) breached twice on CI: 5.11s (PR #2336 run 1) and 5.10s (run 2, unrelated diff). Two consecutive near-identical readings are suspicious both ways: too consistent for pure runner noise, too close to the line for an obvious algorithmic blow-up.- Budget dance: 5.0s baseline → briefly re-tuned to 6.5s (commit
95504c0d4) → reverted to 5.0s + test quarantined (@pytest.mark.quarantine) in commitf844a057f(verified viagit log -S "@pytest.mark.quarantine" -- tests/retrospective/test_summary_tolerance.py; the earlier-citedf52764466is an unrelated SSOT/seam-consolidation commit). Variance collection via the non-blockingquarantine-visibilityjob started at that merge. These are the honest budget-dance endpoints the flippable-oracle bisection should anchor on.
Measured leads (post-spec squad, verify against live corpus)
YAML()object construction is NOT the hotspot. Measured with the repo's ruamel:YAML(typ="safe")construction ≈ 38µs → ×200 ≈ 7.7ms ≈ 0.15% of the 5000ms budget; hoisting a single reused instance saves ~9.4ms ≈ 0.19%. The breach was ~100–110ms over budget — an order of magnitude larger. So a YAML()-hoist "fix" would be a red herring.- Dominant per-mission cost is in
reader.py+schema.py, notsummary.py.summary.py:421is only the call siterecord = read_record(retro_path). The parse/validate cost isread_record(reader.py:333) →_load_yaml_mappingYAML safe-load (reader.py:117) →_coerce_legacy_schema_versions(reader.py:355) →RetrospectiveRecord.model_validate(...)called atreader.py:355(model defined atschema.py:380), run ×200 — the "YAML load paths" candidate. Profile must confirm. - The bisection window must span
reader.py schema.py summary.py, not summary.py alone. #1888/#2119 don't touchsummary.py(terminus-classification → #8219733727df;_resolve_summary_record_path→ #18508544012fa; #2119 only did a no-op literal→constant swapecf45f52c). Prime suspect: commit0818c7590(#1778) rewrote_load_yaml_mapping, inserted a per-record_coerce_legacy_schema_versionspass on the hot path, and editedtest_summary_tolerance.py— all without touchingsummary.py, so asummary.py-only bisect steps right over it. Confirm the corpus exercises a suspected branch before trusting a flat delta (the retrospective-only corpus writesretrospective.yamlwith nometa.json, so some read-path branches are dead in this workload).
Methodology (binding)
1. Profiling (IC-01): build the test's 200-mission corpus; cProfile build_summary ≥5 runs; report median + min–max spread and a per-phase breakdown (fs scan / YAML parse / reduce). Function-level cProfile naturally separates YAML.__init__ from YAML.load. 2. Bisection (IC-02): the oracle must be proven flippable before trusting a "no regression" result — establish a known-bad endpoint that reads red on this hardware, or use a relative per-phase-delta threshold. Fast local hardware makes the raw assert elapsed < 5.0 oracle prone to marking every commit GOOD (false negative). If the oracle can't flip → verdict = inconclusive, not "no regression." 3. Disposition (IC-03): exactly one of — real regression → root fix + restore 5.0s + lift quarantine; CI-flake → dedicated non-blocking perf slice or local_only. Never a budget bump. Week-long CI-variance collection = maintainer follow-up.
Deliverable
docs/plans/engineering-notes/2342-retro-summary-nfr/report.md — verdict + evidence (profiling table, bisection outcome) + one recommended disposition with enactment + quarantine-lift steps. Conditional minimal summary.py fix only if clean and low-risk.
Key files
src/specify_cli/retrospective/summary.py(build_summary; theread_recordcall site at ~:421)src/specify_cli/retrospective/reader.py(read_record:333,_load_yaml_mapping:117,_coerce_legacy_schema_versions:355 — the real hot path)src/specify_cli/retrospective/schema.py(RetrospectiveRecordmodel def :380; validated via.model_validate(...)atreader.py:355)tests/retrospective/test_summary_tolerance.py::test_200_missions_under_5s(quarantined; corpus fixture)tests/_support/quarantine.py;docs/guides/testing-flakiness.md