From 82cd0bdb176ed3b4d817ab88946007ed5a1f89ba Mon Sep 17 00:00:00 2001 From: joyful-ii-V-I Date: Tue, 13 Oct 2026 13:03:20 -0400 Subject: [PATCH] perf(cochange): build StaticIncludeCoupling once per ingest, not per probe cochangePartners built a whole-ingest StaticIncludeCoupling (buildPreciseIncludeAdj over every include) on EVERY call. Its only input is the ingest, so the multi-probe callers (--pr-context, --situ text and facts) rebuilt the same graph once per changed file, and --pr-context again per trim-ladder render pass: ~87% of a --pr-context=HEAD~30 run on ripwire. New CoCouplingCache holds one lazily-built coupling keyed on the ingest it was made for (EXPECTS on a mismatched ingest); the precomputed-sets overload takes an optional pointer to it, and writePrContext / writeSituation / computeSituationFacts pass one per call. Single-probe callers (CLI --cochange, MCP cochange) keep a one-off local cache. Output is byte-identical. Co-Authored-By: Claude Opus 5.5 --- src/gitmine.h | 36 +++++++++++++++++++++++++++++++++--- src/prcontext.h | 3 ++- src/situ.h | 6 ++++-- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/gitmine.h b/src/gitmine.h index b11fb4073..e3dcbe013 100644 --- a/src/gitmine.h +++ b/src/gitmine.h @@ -28,6 +28,7 @@ #include #include #include +#include // CoCouplingCache — the lazily-built StaticIncludeCoupling shared across probes #include // gitRepoToplevel's per-directory memo — one rev-parse probe per root, not per miner #include #include @@ -2692,6 +2693,32 @@ class StaticIncludeCoupling mutable HashMap> memo_; }; +// One StaticIncludeCoupling per INGEST, built on first need and shared by every cochangePartners probe of +// that ingest (prcontext-hoist, CHECKLIST 30(a)). The coupling is a pure function of `ing` alone (its +// include list + buildPreciseIncludeAdj), so a multi-probe caller (--pr-context, --situ) rebuilt the SAME +// whole-ingest include graph once per changed file — ~87% of a --pr-context=HEAD~30 run. The key is the +// ingest itself, held by reference: the cache cannot answer for another one (EXPECTS in get), and it dies +// with the caller's scope, before `ing` does. Lazy, so a probe that never clears kSupport (no git, a clean +// tree) still builds nothing — exactly the per-call cost it had before. +class CoCouplingCache +{ +public: + explicit CoCouplingCache( const IngestResult& ing ) : ing_( ing ) {} + const StaticIncludeCoupling& get( const IngestResult& ing ) + { + EXPECTS( &ing == &ing_, "a CoCouplingCache answers only for the ingest it was built for" ); + if( !coupling_ ) + { + coupling_.emplace( ing_ ); + } + return *coupling_; + } + +private: + const IngestResult& ing_; + std::optional coupling_; +}; + // ─── recurrence across sub-windows (Clio, ICSE 2011) ────────────────────────────────────────────── // // THE DEFECT THIS CLOSES. `--cochange` mined ONE window (18 months by default) and reported any pair @@ -2907,7 +2934,8 @@ inline const char* coPairAttr( const CoPartner& p ) noexcept { return coPairAttr // `outSubWindows` (optional): the recurrence denominator this call actually used, for the emitter's // `sub_windows=` disclosure. Nobody may publish recur= without it — see kCoRecurSubWindows above. inline std::vector cochangePartners( const IngestResult& ing, std::string_view fileSubstr, std::uint32_t& outCommits, - const std::vector>& sets, std::uint32_t* outSubWindows = nullptr ) + const std::vector>& sets, std::uint32_t* outSubWindows = nullptr, + CoCouplingCache* couplingCache = nullptr ) // multi-probe callers share one (see CoCouplingCache) { outCommits = 0; if( outSubWindows ) @@ -2959,7 +2987,9 @@ inline std::vector cochangePartners( const IngestResult& ing, std::st // A4-F22 / P9.1: "surprising" must mean "no static #include coupling *either way, transitively*" — // see StaticIncludeCoupling above, the ONE predicate shared with --cochange's repo-wide pair scan // in main.cpp (P9.1 was the two paths disagreeing). - const StaticIncludeCoupling coupling( ing ); + // Built at most once per ingest when the caller passes `couplingCache`; a one-off local cache otherwise. + std::optional localCache; + CoCouplingCache& cache = couplingCache ? *couplingCache : localCache.emplace( ing ); constexpr std::uint32_t kSupport = 3; for( const auto& [other, n] : together ) { @@ -2970,7 +3000,7 @@ inline std::vector cochangePartners( const IngestResult& ing, std::st const auto maskIt = recurMask.find( other ); res.push_back( { other, n, coConfidence( n, freq[fid] ), coConfidence( n, freq[other] ), coRecurrenceOf( maskIt == recurMask.end() ? 0u : maskIt->second ), - isDepCapable && !coupling.isStaticallyCoupled( fid, other ), isDepCapable } ); + isDepCapable && !cache.get( ing ).isStaticallyCoupled( fid, other ), isDepCapable } ); } } std::sort( res.begin(), res.end(), [ & ]( const CoPartner& x, const CoPartner& y ) diff --git a/src/prcontext.h b/src/prcontext.h index 6db4a7913..4b7ad300b 100644 --- a/src/prcontext.h +++ b/src/prcontext.h @@ -1098,6 +1098,7 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest // file — unbounded storm). Deterministic for a fixed HEAD, so every per-file probe below is identical. // Mined ONCE here even when the budget forces several render passes below (each pass is pure re-emission). const auto coSets = gitCommitFileSets( root, ing, "18 months ago", 30, nullptr, onlyRoot ); + CoCouplingCache coCoupling( ing ); // prcontext-hoist: ONE include-coupling build for every file and every render pass const std::string coWindow = defaultWindowLabel( root, "18mo" ); // F2: had no window to read it against // A4-P?: mine file OWNERSHIP ONCE too (was gitFileAuthors(root, ing, f) — one `git log` popen PER changed @@ -1278,7 +1279,7 @@ inline int writePrContext( std::FILE* out, const std::string& root, const Ingest // (6) co-change partners NOT in the diff (gitmine). Degrades to an empty list without git. A4-P10: // answered from the once-mined `coSets` (no per-file `git log` popen). std::uint32_t commits = 0; - const std::vector partners = cochangePartners( ing, ing.files[f], commits, coSets ); + const std::vector partners = cochangePartners( ing, ing.files[f], commits, coSets, nullptr, &coCoupling ); std::vector outside; for( const CoPartner& p : partners ) { diff --git a/src/situ.h b/src/situ.h index 6aa77e842..800a6a105 100644 --- a/src/situ.h +++ b/src/situ.h @@ -835,6 +835,7 @@ inline void writeSituation( std::FILE* out, const std::string& root, const Inges // A4-P10: mine the commit file-sets ONCE (not one `git log` popen per probed file) and answer every probe // from the shared sets — identical result (mining is deterministic for a fixed HEAD), no subprocess storm. const auto coSets = gitCommitFileSets( root, ing, "18 months ago", 30, nullptr, onlyRoot ); + CoCouplingCache coCoupling( ing ); // prcontext-hoist: one include-coupling build for every probe const std::string coWindow = defaultWindowLabel( root, "18mo" ); // F2: see SituationFacts::coWindow const std::size_t coCommits = coSets.size(); HashMap partnerDeg; @@ -852,7 +853,7 @@ inline void writeSituation( std::FILE* out, const std::string& root, const Inges } ++probed; std::uint32_t commits = 0; - const std::vector ps = cochangePartners( ing, ing.files[f], commits, coSets ); + const std::vector ps = cochangePartners( ing, ing.files[f], commits, coSets, nullptr, &coCoupling ); for( const CoPartner& p : ps ) { if( !changedFile[p.fileId] ) @@ -1007,6 +1008,7 @@ inline SituationFacts computeSituationFacts( const std::string& root, const Inge // A4-P10: mine the commit file-sets ONCE and answer every probe from the shared sets (was one `git log` // popen per probed file — up to 40 — the O(files)-subprocess storm). Deterministic for a fixed HEAD. const auto coSets = gitCommitFileSets( root, ing, "18 months ago", 30 ); + CoCouplingCache coCoupling( ing ); // prcontext-hoist: one include-coupling build for every probe facts.declDef = declDefPartners( ing, changedFile ); // F3: same relationship, same rule, one implementation facts.siblings = lexicalSiblings( ing, changedFile ); // L-D: the same list the CLI report's [1] prints facts.coWindow = defaultWindowLabel( root, "18mo" ); // F2: the composed zero's window travels WITH the zero @@ -1022,7 +1024,7 @@ inline SituationFacts computeSituationFacts( const std::string& root, const Inge } ++probed; std::uint32_t commits = 0; - const std::vector ps = cochangePartners( ing, ing.files[f], commits, coSets ); + const std::vector ps = cochangePartners( ing, ing.files[f], commits, coSets, nullptr, &coCoupling ); for( const CoPartner& p : ps ) { if( !changedFile[p.fileId] )