Class: Rigor::Protection::MutationCache
- Inherits:
-
Object
- Object
- Rigor::Protection::MutationCache
- Defined in:
- lib/rigor/protection/mutation_cache.rb
Overview
Issue #134 slice 2 — the per-file cache for an ADR-63 Tier 2 measurement.
A whole-project rigor coverage --protection --mutation run is Σ(1 + N_f) single-file analyses (the
investigation on #134: ≈94% of the wall time, and rising with the target set). Every one of them is
recomputed on every invocation, including for files nobody has touched since the last run. This stores
one Rigor::Protection::MutationScanner::FileResult per file and serves it back when nothing it could depend on moved.
What "nothing moved" means — the cache key
The KEY carries the inputs that are known before the measurement and are not files:
Rigor::VERSION+ this class's SCHEMA + the Cache::Descriptor schema — an engine upgrade is an ABI boundary for a Marshal'dFileResultand a measurement-semantics boundary for everything else. A VERSION pins the engine's bytes only for a RELEASED gem, so a checkout adds Cache::EngineSource's source digest alongside it (#285) — without it, editing the analyzer and re-measuring serves the pre-edit kill counts back, which is the exact failure a mutation score exists to detect.- The Cache::IncrementalSnapshot fingerprint the dependency edges came from — itself a digest of the
resolved configuration, the analysis roots,
Gemfile.lock,rbs_collection.lock.yaml, and the project's ownsig/contents. This is how a config orsig/edit invalidates the measurement. Since #289 that fingerprint carries the engine-source digest too, which makes the slot above redundant on paper — keep it anyway: this key states its own soundness rather than inheriting it from how another cache happens to compose its key today, and a memoised digest costs nothing to repeat. --limit/--seed/ the site selector: the report is already an estimate under a sample, and without them a--limit 20run would silently serve a--limit 5result.- The sorted set of ADOPTED bleeding-edge feature ids that change this measurement (#255's principle: a
behaviour feature's id enters the cache identity of everything it changes).
discovery-seeded-mutation- sitesmoves both the denominator and the kills;dependent-closure-kill-oraclemoves the kills — and that one additionally BYPASSES this cache entirely (see below). - The whole-project ProjectScan tables (
synthetic_method_index,project_patched_methods) by CONTENT DIGEST, plus the ADR-88 plugin fact-surface digest. Any file may contribute a row to those tables, so they go in by table diff, never by edge attribution — the same shape and the same reason as ADR-67 WD6c'sparam_seed_invalidation. - When the discovery seed is active, the seed's identity (see MutationCache.seed_digest).
The per-file FILE dependencies ride the ADR-45 record-and-validate side instead: the entry stores a
Cache::Descriptor of the measured file plus every deps[A] edge the ADR-46 snapshot recorded for it,
and Cache::Store#peek_validated re-validates them against the filesystem on the next run. deps[A] was
recorded during a full check run with cross-file discovery ON, while the Tier-2 oracle re-analyses the
mutant with discovery OFF (or seeded — see the seed slot above), so the recorded set is a strict SUPERSET
of what the oracle reads: over-invalidating, never under.
Degradation — never a silent hit
The cache disables itself, wholesale, when it cannot prove that much: no reusable snapshot (nobody has
run rigor check --incremental for this project / these roots), a plugin with no incremental fingerprint
surface (ADR-88's opaque case), tables it cannot digest, or --no-cache. Per file, a path the snapshot
never analysed has no deps[A] entry, which means "depends on every project file" — a miss for that file
alone. Every degradation is reported on stderr by the caller, because a measurement that quietly stopped
caching and a measurement that quietly served a stale number look identical from the outside.
dependent-closure-kill-oracle (#254) bypasses the cache rather than keying on it: under that oracle a
file's verdict depends on the diagnostics of its DEPENDENTS, so validity would need the dependencies of
every dependent — a strictly wider edge set than deps[A]. The feature is presumptively non-graduating
(#254's closing comments), so a permanent bypass is the honest answer, not the key complexity.
Defined Under Namespace
Classes: Sampling
Constant Summary collapse
- SCHEMA =
Bumped when the stored value's shape or the key's composition changes, so entries written by an older Rigor read as misses rather than as a differently-meant number.
1- PRODUCER_ID =
"protection.mutation-file-result"- GENERATION_CAP =
One live entry per (file, key) at once — the per-file producer shape, so the Cache::Store#evict! generation pass leaves it alone and only the size-based LRU pass can touch it.
Cache::Store::UNBOUNDED_GENERATIONS
- NO_SNAPSHOT =
Human-readable reasons the caller prints. Each names something the operator can act on.
"no reusable `rigor check --incremental` snapshot"- OPAQUE_PLUGIN =
"a plugin contributes types with no incremental fingerprint surface"- UNDIGESTIBLE =
"the project-scan tables could not be digested"- UNIDENTIFIED_ENGINE =
"the engine's own source tree could not be digested"
Instance Attribute Summary collapse
-
#hits ⇒ Object
readonly
Returns the value of attribute hits.
-
#misses ⇒ Object
readonly
Returns the value of attribute misses.
-
#reason ⇒ Object
readonly
Returns the value of attribute reason.
Class Method Summary collapse
-
.build(configuration:, roots:, project_scan:, sampling:, feature_ids:, seed_inputs: nil, bypass_reason: nil) ⇒ MutationCache
Enabled, or a disabled instance carrying
#reason. - .build_store(configuration) ⇒ Object
- .config_entry(key, payload) ⇒ Object
- .disabled(reason) ⇒ Object
-
.engine_identifiable? ⇒ Boolean
Probed BEFORE the snapshot load, for the reason string rather than for soundness.
-
.engine_source_entries ⇒ Object
Issue #285 — one extra slot pinning the ENGINE'S OWN SOURCE when
Rigor::VERSIONdoes not (a checkout rather than a released gem). -
.key_configs(fingerprint:, project_scan:, sampling:, feature_ids:, seed_inputs:) ⇒ Object
The shared (path-independent) key slots, or a String reason when an input cannot be digested.
-
.load_snapshot(configuration:, roots:) ⇒ Object
The
[fingerprint, payload]of the on-disk ADR-46 snapshot under any plausible root set, or nil. -
.patched_rows(patched) ⇒ Object
ProjectPatchedMethodshas no plain-data rendering of its own; each row is flattened here, with the recorded return type reduced to its short description (the value is a liveRigor::Type). -
.relative_to_pwd(path) ⇒ Object
pathrendered relative to the working directory when it lies underneath it; unchanged otherwise. -
.seed_digest(seed_inputs) ⇒ Object
The identity of the cross-file DiscoverySeed, as the digest of its INPUTS: the sorted
(path, content digest)rows of the files it spans. - .snapshot_root_candidates(roots, configured) ⇒ Object
-
.table_digest(project_scan) ⇒ Object
A stable content digest of the two whole-project pre-pass tables, over their own plain-data renderings.
Instance Method Summary collapse
- #enabled? ⇒ Boolean
-
#fetch(path) ⇒ MutationScanner::FileResult?
The cached result for
path, or nil on any miss (including "this cache is disabled"). -
#initialize(store:, sources:, key_configs:, reason: nil) ⇒ MutationCache
constructor
A new instance of MutationCache.
-
#store(path, result) ⇒ Object
Persist one measured file.
Constructor Details
#initialize(store:, sources:, key_configs:, reason: nil) ⇒ MutationCache
Returns a new instance of MutationCache.
249 250 251 252 253 254 255 256 257 |
# File 'lib/rigor/protection/mutation_cache.rb', line 249 def initialize(store:, sources:, key_configs:, reason: nil) @store = store @sources = sources @key_configs = key_configs @reason = reason @hits = 0 @misses = 0 @snapshot_keys = {} end |
Instance Attribute Details
#hits ⇒ Object (readonly)
Returns the value of attribute hits.
259 260 261 |
# File 'lib/rigor/protection/mutation_cache.rb', line 259 def hits @hits end |
#misses ⇒ Object (readonly)
Returns the value of attribute misses.
259 260 261 |
# File 'lib/rigor/protection/mutation_cache.rb', line 259 def misses @misses end |
#reason ⇒ Object (readonly)
Returns the value of attribute reason.
259 260 261 |
# File 'lib/rigor/protection/mutation_cache.rb', line 259 def reason @reason end |
Class Method Details
.build(configuration:, roots:, project_scan:, sampling:, feature_ids:, seed_inputs: nil, bypass_reason: nil) ⇒ MutationCache
Returns enabled, or a disabled instance carrying #reason.
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 |
# File 'lib/rigor/protection/mutation_cache.rb', line 103 def build(configuration:, roots:, project_scan:, sampling:, feature_ids:, seed_inputs: nil, bypass_reason: nil) return disabled(bypass_reason) if bypass_reason return disabled(UNIDENTIFIED_ENGINE) unless engine_identifiable? matched = load_snapshot(configuration: configuration, roots: roots) return disabled(NO_SNAPSHOT) if matched.nil? fingerprint, payload = matched configs = key_configs(fingerprint: fingerprint, project_scan: project_scan, sampling: sampling, feature_ids: feature_ids, seed_inputs: seed_inputs) return disabled(configs) if configs.is_a?(String) new(store: build_store(configuration), sources: payload.sources, key_configs: configs) end |
.build_store(configuration) ⇒ Object
123 124 125 |
# File 'lib/rigor/protection/mutation_cache.rb', line 123 def build_store(configuration) Cache::Store.new(root: configuration.cache_path, max_bytes: configuration.cache_max_bytes) end |
.config_entry(key, payload) ⇒ Object
204 205 206 |
# File 'lib/rigor/protection/mutation_cache.rb', line 204 def config_entry(key, payload) Cache::Descriptor::ConfigEntry.new(key: key, value_hash: Digest::SHA256.hexdigest(payload)) end |
.disabled(reason) ⇒ Object
119 120 121 |
# File 'lib/rigor/protection/mutation_cache.rb', line 119 def disabled(reason) new(store: nil, sources: {}, key_configs: [], reason: reason) end |
.engine_identifiable? ⇒ Boolean
Probed BEFORE the snapshot load, for the reason string rather than for soundness. Since #289
Cache::IncrementalSnapshot.fingerprint folds the same digest in and answers a bare nil when it
cannot, so an unidentifiable engine already reaches the user as a disabled cache — but as
NO_SNAPSHOT, which tells them to run rigor check --incremental, a fix for a different problem.
The Cache::EngineSource::Unavailable rescue in key_configs stays as the backstop for a tree
that becomes unreadable between here and there.
197 198 199 200 201 202 |
# File 'lib/rigor/protection/mutation_cache.rb', line 197 def engine_identifiable? Cache::EngineSource.process_identity true rescue Cache::EngineSource::Unavailable false end |
.engine_source_entries ⇒ Object
Issue #285 — one extra slot pinning the ENGINE'S OWN SOURCE when Rigor::VERSION does not (a
checkout rather than a released gem). Empty for a released install, so its key composition is
unchanged and SCHEMA needs no bump; an engine that cannot be identified raises through to the
Unavailable rescue above and disables the cache, which is the only sound reading of a key slot
that could not be computed.
186 187 188 189 |
# File 'lib/rigor/protection/mutation_cache.rb', line 186 def engine_source_entries identity = Cache::EngineSource.process_identity identity.nil? ? [] : [config_entry("engine-source", identity)] end |
.key_configs(fingerprint:, project_scan:, sampling:, feature_ids:, seed_inputs:) ⇒ Object
The shared (path-independent) key slots, or a String reason when an input cannot be digested.
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 |
# File 'lib/rigor/protection/mutation_cache.rb', line 159 def key_configs(fingerprint:, project_scan:, sampling:, feature_ids:, seed_inputs:) facts = Analysis::PluginFactFingerprint.key_digest(project_scan.plugin_registry) return OPAQUE_PLUGIN if facts.nil? tables = table_digest(project_scan) return UNDIGESTIBLE if tables.nil? (engine_source_entries + [ config_entry("engine", "#{Rigor::VERSION}:#{SCHEMA}:#{Cache::Descriptor::SCHEMA_VERSION}"), config_entry("snapshot", fingerprint), config_entry("sampling", sampling.to_h.sort.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")), config_entry("bleeding-edge", feature_ids.map(&:to_s).sort.join(",")), config_entry("project-tables", tables), config_entry("plugin-facts", facts), config_entry("discovery-seed", seed_digest(seed_inputs)) ]).freeze rescue Cache::EngineSource::Unavailable UNIDENTIFIED_ENGINE rescue StandardError UNDIGESTIBLE end |
.load_snapshot(configuration:, roots:) ⇒ Object
The [fingerprint, payload] of the on-disk ADR-46 snapshot under any plausible root set, or nil.
A snapshot is keyed to the ROOTS its writing run was invoked with, and a measurement has no way to
know which form that was: rigor check --incremental lib records ["lib"], a bare rigor check --incremental records the configured paths: (absolute), and a measurement of a SUBDIRECTORY
shares neither. All four renderings are offered, one blob read (see IncrementalSnapshot#load_any).
Accepting a snapshot whose roots differ from the measured paths is sound rather than lax: the snapshot contributes per-file dependency edges only, and a file it never analysed simply has no entry — which this cache already treats as a miss for that file.
137 138 139 140 141 142 |
# File 'lib/rigor/protection/mutation_cache.rb', line 137 def load_snapshot(configuration:, roots:) candidates = snapshot_root_candidates(roots, configuration.paths).map do |candidate| Cache::IncrementalSnapshot.fingerprint(configuration: configuration, roots: candidate) end Cache::IncrementalSnapshot.new(root: configuration.cache_path).load_any(fingerprints: candidates) end |
.patched_rows(patched) ⇒ Object
ProjectPatchedMethods has no plain-data rendering of its own; each row is flattened here, with the
recorded return type reduced to its short description (the value is a live Rigor::Type).
223 224 225 226 227 228 |
# File 'lib/rigor/protection/mutation_cache.rb', line 223 def patched_rows(patched) patched.by_key.map do |(class_name, method_name, kind), entry| [class_name.to_s, method_name.to_s, kind.to_s, entry.source_path.to_s, entry.source_line, entry.return_type&.describe(:short).to_s] end.sort end |
.relative_to_pwd(path) ⇒ Object
path rendered relative to the working directory when it lies underneath it; unchanged otherwise.
Shared by the root-candidate list and the per-file snapshot lookup, which face the same skew.
152 153 154 155 156 |
# File 'lib/rigor/protection/mutation_cache.rb', line 152 def relative_to_pwd(path) absolute = File.(path) prefix = "#{File.(Dir.pwd)}#{File::SEPARATOR}" absolute.start_with?(prefix) ? absolute.delete_prefix(prefix) : path.to_s end |
.seed_digest(seed_inputs) ⇒ Object
The identity of the cross-file DiscoverySeed, as the digest of its INPUTS: the sorted
(path, content digest) rows of the files it spans. The seed is a deterministic function of those
contents and of inputs already in the key (the resolved configuration and the plugin fact surface,
which fix the environment the parameter-inference pre-pass runs under), so hashing the inputs is at
least as strong an invalidation signal as hashing the built tables would be — and it never has to
traverse discovered_def_nodes, whose values are live Prism::Nodes with no stable identity.
Because the seed spans the whole measured path set, ANY measured file's edit invalidates EVERY file's entry while the feature is adopted. That is the correct reading of "the seed is an input to every file's measurement", not a defect of this digest.
240 241 242 243 244 245 246 |
# File 'lib/rigor/protection/mutation_cache.rb', line 240 def seed_digest(seed_inputs) return "off" if seed_inputs.nil? digest = Digest::SHA256.new seed_inputs.sort.each { |path| digest << path << "\0" << Cache::FileDigest.hexdigest(path) << "\0" } digest.hexdigest end |
.snapshot_root_candidates(roots, configured) ⇒ Object
144 145 146 147 148 |
# File 'lib/rigor/protection/mutation_cache.rb', line 144 def snapshot_root_candidates(roots, configured) [roots, Array(roots).map { |path| File.(path) }, configured, Array(configured).map { |path| relative_to_pwd(path) }] .compact.reject(&:empty?).uniq end |
.table_digest(project_scan) ⇒ Object
A stable content digest of the two whole-project pre-pass tables, over their own plain-data
renderings. nil when either declines to render (→ the cache disables itself rather than key on a
table it cannot see).
211 212 213 214 215 216 217 218 219 |
# File 'lib/rigor/protection/mutation_cache.rb', line 211 def table_digest(project_scan) payload = { "synthetic" => project_scan.synthetic_method_index.to_h, "patched" => patched_rows(project_scan.project_patched_methods) } Digest::SHA256.hexdigest(JSON.generate(payload)) rescue StandardError nil end |
Instance Method Details
#enabled? ⇒ Boolean
261 262 263 |
# File 'lib/rigor/protection/mutation_cache.rb', line 261 def enabled? @reason.nil? end |
#fetch(path) ⇒ MutationScanner::FileResult?
The cached result for path, or nil on any miss (including "this cache is disabled").
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 |
# File 'lib/rigor/protection/mutation_cache.rb', line 267 def fetch(path) return nil unless enabled? # A path the snapshot never analysed has no recorded `deps[A]`, which means "depends on every project # file". Answering that from the key alone keeps the read path free of the digesting the WRITE path # does — the stored entry's own dependency descriptor is what a hit is validated against. return miss if snapshot_key(path).nil? value = @store.peek_validated(producer_id: PRODUCER_ID, key_descriptor: key_descriptor, params: params_for(path)) return miss unless value.is_a?(MutationScanner::FileResult) @hits += 1 value rescue StandardError miss end |
#store(path, result) ⇒ Object
Persist one measured file. Returns false when nothing was written.
A result carrying harness_errors (#264 — mutants that blew up INSIDE the measurement harness) is
never persisted: that run measured the harness rather than the code, and freezing it into a warm hit
would make a transient failure permanent and invisible.
289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 |
# File 'lib/rigor/protection/mutation_cache.rb', line 289 def store(path, result) return false unless enabled? return false unless result.is_a?(MutationScanner::FileResult) return false if result.harness_errors.positive? dependency = dependencies(path) return false if dependency.nil? @store.fetch_or_validate(producer_id: PRODUCER_ID, key_descriptor: key_descriptor, params: params_for(path), generation_cap: GENERATION_CAP) do [result, dependency] end true rescue StandardError false end |