From fd717b17f5ce2712680fd23ad5e6ab64058505d2 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 04:21:32 +0000 Subject: [PATCH 1/5] Typed-module materialization across runs: the host-persisted tier Implements the C3 rung of gunbc.floor_materialization: a host-local, content-addressed backing for the typed-module snapshots the in-process tiers already produce, so the value survives the process that computed it. Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/floor/floor_materialization.dag | 164 +++++ .../floor_materialization_witness_test.dag | 79 ++- src/v1/stage0/src/cli_run.rs | 77 ++- src/v1/stage0/src/shared_typecheck_store.rs | 640 ++++++++++++++++++ 4 files changed, 956 insertions(+), 4 deletions(-) diff --git a/dag/gunbc/floor/floor_materialization.dag b/dag/gunbc/floor/floor_materialization.dag index 1488b9475cf..423a8bf74da 100644 --- a/dag/gunbc/floor/floor_materialization.dag +++ b/dag/gunbc/floor/floor_materialization.dag @@ -206,3 +206,167 @@ fn floor_typecheck_store_materialization_without_provider() -> ValueMaterializat providers: floor_typecheck_store_no_providers ) } + +// C3 — the HOST-PERSISTED tier over the SAME identity as C2. C2 discharges typed-module +// plurality inside ONE executor process; this row says where the value lives when that process is +// gone. The frame is UnboundedSiblingsFrame, not SharedStateFrame: recurring invocations of the +// fold on a runner host are successive siblings with no shared mutable state to rewire — there is +// no LCA process in which the repetition could have been carried, which is precisely the ladder's +// isolation-LCA obligation that only a STORE tier can discharge. ArtifactTier, not CasTier: +// tier_axes assigns CasTier the RemoteNetwork placement, and this store is a host-local directory +// (LocalFilesystem) — the same axis reading that puts extdeps.realization.resolved_graph's +// per-host content-addressed store at ArtifactTier. +// +// WHAT THIS DELIVERS TODAY, named so the row is not read as more than it is: the typed-module +// content key's compiler-identity term is the running binary's content digest, and build.rs embeds +// the checkout's commit in that binary, so a rebuild at ANY new commit — including a corpus-only +// .dag commit — re-keys every entry. The relief therefore covers SAME-BUILD, CROSS-PROCESS reuse: +// the witness fold and the generated-artifact drift gate are two processes over one corpus running +// one built binary, and the second pays typecheck preparation again today. Cross-push reuse is a +// separate change to how compiler identity is derived and is NOT claimed by this row. + +data floor_typecheck_store_persist_provider_id: String = "host_persisted_typecheck_store" + +fn floor_runner_host_frame() -> std.materialization_ladder.Frame { + std.materialization_ladder.Frame { name: "runner-host", kind: UnboundedSiblingsFrame } +} + +// The ladder refuses ReleasedNever with CapacityUnbounded (RetentionUnbounded) and with +// CapacityUnobserved (RetentionUnobserved), so a byte ceiling and an at-capacity disposition are +// OBLIGATIONS this row discharges rather than options it selects. ByteCapacity and not +// EntryCountCapacity: the ceiling that matters on a runner host is disk, and an entry bound over a +// payload whose size is not fixed establishes no byte bound. RefuseNewStore and not +// ReplaceExisting: replacement is a second mechanism with its own correctness surface, and this +// bridge does not need one — a full store declines publication, counts the refusal, and the run is +// merely cold. Its realization is shared_typecheck_store PersistentTypedStore, held in lockstep by +// persist_cap_matches_modeled_authority. + +data floor_typecheck_store_persist_cap_bytes: ByteSize = byte_size(count: 8589934592) + +data floor_typecheck_store_persist_provider: CacheProvider = provider_row( + id: floor_typecheck_store_persist_provider_id, + scope: [floor_runner_host_frame()], + coverage: CoversIdentities { identities: [floor_typecheck_store_identity] }, + tier: ArtifactTier { keying: ContentKeyed }, + retention: persistent_retention(capacity: CapacityBounded { + limit: ByteCapacity { limit: ExactLimit { value: floor_typecheck_store_persist_cap_bytes } }, + at_capacity: RefuseNewStore + }) +) + +// The two sibling processes that each demand union-typecheck-store(dag,src/v2) over one corpus +// under one built binary. They execute apart by construction, so the repetition between them is +// not authored duplication a rewire could remove — their obligation LCA is the runner host, which +// is not a shared-state frame, and only a STORE tier reaches across it. The demand nature is +// PureComputation: same module text, same resolved dependency closure, same compiler binary ⇒ same +// typed result, which is what makes ContentKeyed reuse sound here without declaring any staleness +// envelope. Nothing is read from the world. + +fn floor_witness_fold_process_frame() -> std.materialization_ladder.Frame { + std.materialization_ladder.Frame { name: "witness-fold-process", kind: SharedStateFrame } +} + +fn floor_drift_gate_process_frame() -> std.materialization_ladder.Frame { + std.materialization_ladder.Frame { name: "drift-gate-process", kind: SharedStateFrame } +} + +fn floor_typecheck_store_cross_process_demands() -> List { + [ + FrameDemand { + identity: floor_typecheck_store_identity, + site: [floor_runner_host_frame(), floor_witness_fold_process_frame()], + nature: PureComputation + }, + FrameDemand { + identity: floor_typecheck_store_identity, + site: [floor_runner_host_frame(), floor_drift_gate_process_frame()], + nature: PureComputation + } + ] +} + +data floor_typecheck_store_persist_providers: List = [floor_typecheck_store_persist_provider] + +data floor_typecheck_store_no_cost_floor_exemptions: List = [] + +fn floor_typecheck_store_persist_verdicts() -> List { + materialization_ladder( + demands: floor_typecheck_store_cross_process_demands(), + providers: floor_typecheck_store_persist_providers, + cost_floor_exempt: floor_typecheck_store_no_cost_floor_exemptions + ) +} + +// RED CONTROLS. Each is the SAME demand set with exactly one obligation withdrawn from the +// provider, so the green above is green because the ladder looked and admitted it, never because +// nothing looked. Each red names the DISTINCT refusal it must produce, so a provider that fails +// for the wrong reason does not pass as the right one. + +data floor_typecheck_store_persist_provider_unbounded: CacheProvider = provider_row( + id: floor_typecheck_store_persist_provider_id, + scope: [floor_runner_host_frame()], + coverage: CoversIdentities { identities: [floor_typecheck_store_identity] }, + tier: ArtifactTier { keying: ContentKeyed }, + retention: persistent_retention(capacity: CapacityUnbounded) +) + +data floor_typecheck_store_persist_provider_unobserved: CacheProvider = provider_row( + id: floor_typecheck_store_persist_provider_id, + scope: [floor_runner_host_frame()], + coverage: CoversIdentities { identities: [floor_typecheck_store_identity] }, + tier: ArtifactTier { keying: ContentKeyed }, + retention: persistent_retention(capacity: CapacityUnobserved) +) + +// Scope too narrow: the in-process row cannot answer a cross-process demand. A reference does not +// survive the process that holds it, which is the whole reason this tier exists — so the existing +// C2 provider, offered alone against these two demands, must REFUSE rather than appear to cover +// them. + +fn floor_typecheck_store_persist_verdicts_unbounded() -> List { + materialization_ladder( + demands: floor_typecheck_store_cross_process_demands(), + providers: [floor_typecheck_store_persist_provider_unbounded], + cost_floor_exempt: floor_typecheck_store_no_cost_floor_exemptions + ) +} + +fn floor_typecheck_store_persist_verdicts_unobserved() -> List { + materialization_ladder( + demands: floor_typecheck_store_cross_process_demands(), + providers: [floor_typecheck_store_persist_provider_unobserved], + cost_floor_exempt: floor_typecheck_store_no_cost_floor_exemptions + ) +} + +// The scope control is the SAME store row with only its scope withdrawn to one process — not the +// C2 ReferenceTier share, which is not a store tier at all and would refuse for the unrelated +// reason that no store provider exists. Withdrawing one obligation at a time is what makes each +// red name its own refusal. + +data floor_typecheck_store_persist_provider_process_scoped: CacheProvider = provider_row( + id: floor_typecheck_store_persist_provider_id, + scope: [floor_executor_process_frame()], + coverage: CoversIdentities { identities: [floor_typecheck_store_identity] }, + tier: ArtifactTier { keying: ContentKeyed }, + retention: persistent_retention(capacity: CapacityBounded { + limit: ByteCapacity { limit: ExactLimit { value: floor_typecheck_store_persist_cap_bytes } }, + at_capacity: RefuseNewStore + }) +) + +fn floor_typecheck_store_persist_verdicts_process_scoped() -> List { + materialization_ladder( + demands: floor_typecheck_store_cross_process_demands(), + providers: [floor_typecheck_store_persist_provider_process_scoped], + cost_floor_exempt: floor_typecheck_store_no_cost_floor_exemptions + ) +} + +fn floor_typecheck_store_persist_verdicts_without_provider() -> List { + materialization_ladder( + demands: floor_typecheck_store_cross_process_demands(), + providers: floor_typecheck_store_no_providers, + cost_floor_exempt: floor_typecheck_store_no_cost_floor_exemptions + ) +} diff --git a/dag/test/claim/floor/floor_materialization_witness_test.dag b/dag/test/claim/floor/floor_materialization_witness_test.dag index 9ed3ae7c7a6..25ac4698581 100644 --- a/dag/test/claim/floor/floor_materialization_witness_test.dag +++ b/dag/test/claim/floor/floor_materialization_witness_test.dag @@ -14,11 +14,23 @@ import gunbc.floor_materialization { floor_typecheck_store_share_provider_id, floor_typecheck_store_materialization, floor_typecheck_store_materialization_without_provider, - floor_typecheck_store_reader_count + floor_typecheck_store_reader_count, + floor_typecheck_store_persist_provider_id, + floor_typecheck_store_persist_verdicts, + floor_typecheck_store_persist_verdicts_unbounded, + floor_typecheck_store_persist_verdicts_unobserved, + floor_typecheck_store_persist_verdicts_process_scoped, + floor_typecheck_store_persist_verdicts_without_provider } import std.materialization_ladder { + LadderVerdict, value_verdict_is_shared_by_reference, - value_verdict_is_refused_needing_reference + value_verdict_is_refused_needing_reference, + verdict_is_discharged_by, + verdict_is_refused_retention_unbounded, + verdict_is_refused_retention_unobserved, + verdict_is_refused_scope_too_narrow, + verdict_is_refused_no_provider } import v2.std.live_tree { LiveTreeDisposition, SubstrateInputsOnly } @@ -75,3 +87,66 @@ test fn floor_typecheck_store_reds_without_reference_provider() -> Bool { identity: floor_typecheck_store_identity ) } + +// C3 — the HOST-PERSISTED tier. The demand set is the two sibling PROCESSES over one corpus under +// one built binary (the witness fold and the generated-artifact drift gate), whose obligation LCA +// is the runner host. That LCA is not shared state, so the repetition is not a rewire the ladder +// would refuse; it is the isolation obligation only a store tier discharges. Four REDs follow, one +// per withdrawn obligation, each naming the DISTINCT refusal it must produce — so the green is +// green because the ladder admitted this provider, and a provider failing for another reason +// cannot pass as this one. + +fn first_persist_verdict(vs: List) -> LadderVerdict? { + vs.first() +} + +test fn floor_typecheck_store_persist_is_discharged() -> Bool { + match first_persist_verdict(vs: floor_typecheck_store_persist_verdicts()) { + Absent => false + Present { value: v } => + verdict_is_discharged_by( + v: v, + identity: floor_typecheck_store_identity, + provider_id: floor_typecheck_store_persist_provider_id + ) + } +} + +// The ladder refuses ReleasedNever with CapacityUnbounded. Without this red, a persistent provider +// with no ceiling would read as discharged and the boundedness obligation would be decoration. +test fn floor_typecheck_store_persist_reds_when_unbounded() -> Bool { + match first_persist_verdict(vs: floor_typecheck_store_persist_verdicts_unbounded()) { + Absent => false + Present { value: v } => + verdict_is_refused_retention_unbounded(v: v, provider_id: floor_typecheck_store_persist_provider_id) + } +} + +// And refuses ReleasedNever with CapacityUnobserved: a ceiling nobody measures is not a ceiling. +test fn floor_typecheck_store_persist_reds_when_occupancy_unobserved() -> Bool { + match first_persist_verdict(vs: floor_typecheck_store_persist_verdicts_unobserved()) { + Absent => false + Present { value: v } => + verdict_is_refused_retention_unobserved(v: v, provider_id: floor_typecheck_store_persist_provider_id) + } +} + +// The same store row scoped to ONE PROCESS, offered against a CROSS-PROCESS demand, must refuse as +// scope-too-narrow. This is the discriminating red for the whole tier: if it passed, the +// process-scoped provider would be answering a cross-process question, and C3 would be redundant +// with C2 rather than reaching a boundary C2 cannot. +test fn floor_typecheck_store_persist_reds_when_only_process_scoped() -> Bool { + match first_persist_verdict(vs: floor_typecheck_store_persist_verdicts_process_scoped()) { + Absent => false + Present { value: v } => + verdict_is_refused_scope_too_narrow(v: v, provider_id: floor_typecheck_store_persist_provider_id) + } +} + +test fn floor_typecheck_store_persist_reds_without_any_provider() -> Bool { + match first_persist_verdict(vs: floor_typecheck_store_persist_verdicts_without_provider()) { + Absent => false + Present { value: v } => + verdict_is_refused_no_provider(v: v, identity: floor_typecheck_store_identity) + } +} diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index d8827ce5d03..9be3db68f27 100644 --- a/src/v1/stage0/src/cli_run.rs +++ b/src/v1/stage0/src/cli_run.rs @@ -14158,6 +14158,14 @@ fn emit_floor_drain_receipt( .unwrap_or_else(|| "unreadable".into()), typed_module_cache_cap(index), ); + // The ladder's RetentionUnobserved refusal is discharged by an OBSERVATION, + // not by a declaration, so the persistent tier reports its live occupancy + // beside its throughput on the same receipt the floor already emits. Absent + // when the tier is not armed: a run that never opened a store must not + // print a zero that reads as an empty one. + if let Some(line) = shared_typecheck_store::persistent_typed_store_receipt_line() { + eprintln!("{line}"); + } } /// Enforce the host-budget-derived entry cap on the private typed cache. Evictions @@ -14202,9 +14210,72 @@ fn index_get_typed( ) -> Result>, String> { let Some(store) = index.cross_worker_store.as_ref() else { shared_typecheck_store::record_private_store_fallback(); - return Ok(index.typed_module_cache.borrow().get(typed_key).cloned()); + if let Some(hit) = index.typed_module_cache.borrow().get(typed_key).cloned() { + return Ok(Some(hit)); + } + return persist_get_typed(index, typed_key); + }; + match shared_get_typed(store, typed_key)? { + Some(hit) => Ok(Some(hit)), + None => persist_get_typed(index, typed_key), + } +} + +/// The host-persisted tier behind both in-process tiers +/// (`gunbc.floor_materialization` `host_persisted_typecheck_store`). A hit here +/// is served BEFORE `collect_parent_envs` and the typecheck compute at the one +/// call site above, which is the expensive production computation this tier +/// exists to skip; it is not a byte load placed after the work has been done. +/// +/// A decode failure on a verified entry is a REFUSAL, not a miss: the entry +/// passed the store's own magic/version/length/key verification, so a payload +/// the transport cannot read is codec drift and the line stops here rather than +/// widening into a silent recompute (DESIGN section 5). Absence, rejection at +/// verification and IO failure are already counted misses inside the store. +fn persist_get_typed( + index: &MultiEntryIndex, + typed_key: &str, +) -> Result>, String> { + let Some(store) = shared_typecheck_store::persistent_typed_store_for(&index.source_roots)? + else { + return Ok(None); }; - shared_get_typed(store, typed_key) + let Some(payload) = store.get(typed_key) else { + return Ok(None); + }; + let decoded = SharedTypecheckCaches::decode_typed_snapshot(payload.as_slice()).map_err(|e| { + format!( + "persistent typed store refused: entry for key '{typed_key}' verified against its header but its payload did not decode ({e}) -- this is codec drift between the store format version and the transport, not a cache miss" + ) + })?; + // Readmit into the in-process tier so the repeat within this process is a + // reference, not a second disk read. + if index.cross_worker_store.is_none() { + index + .typed_module_cache + .borrow_mut() + .insert(typed_key.to_string(), decoded.clone()); + enforce_typed_cache_entry_cap(index); + } + Ok(Some(decoded)) +} + +/// Write-through to the host-persisted tier. Publication failures (IO, ceiling +/// reached) are counted inside the store and never fail the run: this tier is +/// an optimization over a pure computation, so an unavailable store costs time +/// and can never change a verdict. +fn persist_put_typed( + index: &MultiEntryIndex, + typed_key: &str, + result: &Rc, +) -> Result<(), String> { + let Some(store) = shared_typecheck_store::persistent_typed_store_for(&index.source_roots)? + else { + return Ok(()); + }; + let bytes = SharedTypecheckCaches::encode_typed_snapshot(result)?; + store.put(typed_key, bytes.as_slice()); + Ok(()) } fn check_index_module_source_identity( @@ -14300,6 +14371,7 @@ fn index_insert_typed( ) -> Result, String> { let Some(store) = index.cross_worker_store.as_ref() else { shared_typecheck_store::record_private_store_fallback(); + persist_put_typed(index, &typed_key, &result)?; index .typed_module_cache .borrow_mut() @@ -14307,6 +14379,7 @@ fn index_insert_typed( enforce_typed_cache_entry_cap(index); return Ok(result); }; + persist_put_typed(index, &typed_key, &result)?; if let Some(bytes) = { let caches = shared_caches_read(store)?; caches.clone_typed_bytes(&typed_key) diff --git a/src/v1/stage0/src/shared_typecheck_store.rs b/src/v1/stage0/src/shared_typecheck_store.rs index 5413d34fecd..764b87c3cdf 100644 --- a/src/v1/stage0/src/shared_typecheck_store.rs +++ b/src/v1/stage0/src/shared_typecheck_store.rs @@ -232,3 +232,643 @@ impl SharedTypecheckCaches { pub fn new_shared_typecheck_caches() -> Arc> { Arc::new(std::sync::RwLock::new(SharedTypecheckCaches::new())) } + +// --------------------------------------------------------------------------- +// Host-persisted backing for the same transport (gunbc.floor_materialization +// host_persisted_typecheck_store). DESIGN section 4.4: the transport above is +// reused as-is; only its BACKING changes from an in-process map to a host +// directory of content-addressed entries, so there is no second representation +// and no second key authority. The key is the caller's `typed_key` +// (std.interface_summary.typed_module_key -- source hash + direct-import +// interface hashes + compiler identity); this layer adds only the two terms a +// LONGER LIFETIME needs and the in-process map never did: the store FORMAT +// version and the SOURCE-ROOT set, both carried as the namespace directory +// rather than folded into the key, so a namespace mismatch cannot collide with +// a content mismatch. +// +// SCOPE OF THE RELIEF DELIVERED HERE, stated so it is not read as more: +// `typed_module_key`'s compiler-identity term is `transform_content_digest()`, +// the content hash of the RUNNING BINARY, and `build.rs` embeds +// `GUNBC_BUILD_IDENTITY` (the checkout's commit) into that binary through +// `cargo:rustc-env`. So a rebuild at ANY new commit -- including a `.dag`-only +// one -- produces different bytes and therefore a different key for every +// module. What this store delivers today is SAME-BUILD, CROSS-PROCESS reuse: +// two processes running ONE built binary over one corpus (the witness fold and +// the generated-artifact drift gate). Cross-push reuse is NOT delivered and is +// not claimed; it needs a compiler identity invariant under a corpus-only +// commit, which is a separate change to that identity's derivation. + +use std::path::{Path, PathBuf}; + +/// Bumped when the encoded payload's meaning changes. Entries under an older +/// version live in a different namespace directory and are never decoded. +const PERSIST_FORMAT_VERSION: u32 = 1; +const PERSIST_MAGIC: &[u8; 8] = b"gunbctms"; +/// Default host byte ceiling. Overridable by `GUNBC_TYPED_STORE_PERSIST_MAX_BYTES`; +/// the modeled authority is `gunbc.floor_materialization` +/// `floor_typecheck_store_persist_cap_bytes`, kept in lockstep by +/// `persist_cap_matches_modeled_authority`. +const PERSIST_DEFAULT_CAP_BYTES: u64 = 8_589_934_592; + +static PERSIST_HIT: AtomicUsize = AtomicUsize::new(0); +static PERSIST_MISS: AtomicUsize = AtomicUsize::new(0); +static PERSIST_READ_BYTES: AtomicUsize = AtomicUsize::new(0); +static PERSIST_WRITE_BYTES: AtomicUsize = AtomicUsize::new(0); +static PERSIST_REJECTED: AtomicUsize = AtomicUsize::new(0); +static PERSIST_REFUSED_AT_CAPACITY: AtomicUsize = AtomicUsize::new(0); +static PERSIST_IO_ERROR: AtomicUsize = AtomicUsize::new(0); + +/// The occupancy the ladder's `RetentionUnobserved` refusal demands: LIVE bytes +/// and entries resident in the store, distinct from the cumulative read/write +/// byte throughput beside them. A throughput counter cannot discharge a +/// residency obligation, so both are carried. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct PersistentTypedStoreCounters { + pub persist_hit: usize, + pub persist_miss: usize, + /// Payload bytes decoded from the store (cumulative throughput). + pub persist_read_bytes: usize, + /// Payload bytes published to the store (cumulative throughput). + pub persist_write_bytes: usize, + /// Entries that were present but failed verification (truncated, wrong + /// magic/version, key mismatch) -- counted, deleted, and served as a MISS. + pub persist_rejected: usize, + /// Publications declined because the byte ceiling was already reached + /// (`RefuseNewStore`). + pub persist_refused_at_capacity: usize, + /// Filesystem operations that failed. The run continues cold; it is never + /// silent. + pub persist_io_error: usize, + /// LIVE occupancy, not throughput: bytes resident in the store. + pub persist_occupancy_bytes: u64, + /// LIVE occupancy, not throughput: entries resident in the store. + pub persist_occupancy_entries: u64, +} + +pub fn persistent_typed_store_counters_snapshot() -> PersistentTypedStoreCounters { + let (bytes, entries) = match persistent_typed_store_if_open() { + Some(store) => store.occupancy(), + None => (0, 0), + }; + PersistentTypedStoreCounters { + persist_hit: PERSIST_HIT.load(Ordering::SeqCst), + persist_miss: PERSIST_MISS.load(Ordering::SeqCst), + persist_read_bytes: PERSIST_READ_BYTES.load(Ordering::SeqCst), + persist_write_bytes: PERSIST_WRITE_BYTES.load(Ordering::SeqCst), + persist_rejected: PERSIST_REJECTED.load(Ordering::SeqCst), + persist_refused_at_capacity: PERSIST_REFUSED_AT_CAPACITY.load(Ordering::SeqCst), + persist_io_error: PERSIST_IO_ERROR.load(Ordering::SeqCst), + persist_occupancy_bytes: bytes, + persist_occupancy_entries: entries, + } +} + +#[doc(hidden)] +pub fn reset_persistent_typed_store_counters_for_test() { + PERSIST_HIT.store(0, Ordering::SeqCst); + PERSIST_MISS.store(0, Ordering::SeqCst); + PERSIST_READ_BYTES.store(0, Ordering::SeqCst); + PERSIST_WRITE_BYTES.store(0, Ordering::SeqCst); + PERSIST_REJECTED.store(0, Ordering::SeqCst); + PERSIST_REFUSED_AT_CAPACITY.store(0, Ordering::SeqCst); + PERSIST_IO_ERROR.store(0, Ordering::SeqCst); +} + +/// The modeled byte ceiling this realization is held under. Read by the +/// lockstep test against `gunbc.floor_materialization`. +pub fn persist_default_cap_bytes() -> u64 { + PERSIST_DEFAULT_CAP_BYTES +} + +pub fn persist_format_version() -> u32 { + PERSIST_FORMAT_VERSION +} + +/// Host-local, content-addressed backing for typed-module snapshots. +/// +/// Every failure arm is a COUNTED miss or a COUNTED refusal and never a +/// fabricated result (DESIGN section 5): a verification mismatch is reported as +/// `persist_rejected`, not folded into `persist_miss`, so "the store is sound" +/// stays falsifiable rather than becoming the arm that absorbs everything. +pub struct PersistentTypedStore { + namespace_root: PathBuf, + cap_bytes: u64, + occupancy: Mutex<(u64, u64)>, +} + +impl PersistentTypedStore { + /// `source_roots` enters the NAMESPACE, not the key: resolution is + /// root-relative, so entries produced under a different root set are a + /// different question and must not be reachable at all. + pub fn open(root: &Path, source_roots: &[String], cap_bytes: u64) -> Result { + let namespace_root = root + .join(format!("v{PERSIST_FORMAT_VERSION}")) + .join(source_root_namespace(source_roots)); + std::fs::create_dir_all(&namespace_root).map_err(|e| { + format!("persistent typed store: cannot create {namespace_root:?}: {e}") + })?; + let occupancy = scan_occupancy(&namespace_root); + Ok(Self { + namespace_root, + cap_bytes, + occupancy: Mutex::new(occupancy), + }) + } + + pub fn occupancy(&self) -> (u64, u64) { + *self + .occupancy + .lock() + .expect("persistent typed store occupancy poisoned") + } + + pub fn cap_bytes(&self) -> u64 { + self.cap_bytes + } + + pub fn namespace_root(&self) -> &Path { + &self.namespace_root + } + + fn entry_path(&self, typed_key: &str) -> PathBuf { + let shard = typed_key.get(0..2).unwrap_or("00"); + self.namespace_root.join(shard).join(typed_key) + } + + /// Verified read. Returns the payload only when magic, format version, + /// declared payload length and the STORED KEY all agree with what was + /// asked for; anything else deletes the entry, counts it, and misses. + pub fn get(&self, typed_key: &str) -> Option> { + let path = self.entry_path(typed_key); + let raw = match std::fs::read(&path) { + Ok(raw) => raw, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + PERSIST_MISS.fetch_add(1, Ordering::SeqCst); + return None; + } + Err(_) => { + PERSIST_IO_ERROR.fetch_add(1, Ordering::SeqCst); + PERSIST_MISS.fetch_add(1, Ordering::SeqCst); + return None; + } + }; + match decode_entry(&raw, typed_key) { + Ok(payload) => { + PERSIST_HIT.fetch_add(1, Ordering::SeqCst); + PERSIST_READ_BYTES.fetch_add(payload.len(), Ordering::SeqCst); + Some(payload) + } + Err(_) => { + PERSIST_REJECTED.fetch_add(1, Ordering::SeqCst); + PERSIST_MISS.fetch_add(1, Ordering::SeqCst); + self.remove_entry(&path, raw.len() as u64); + None + } + } + } + + fn remove_entry(&self, path: &Path, stored_len: u64) { + if std::fs::remove_file(path).is_ok() { + let mut occ = self + .occupancy + .lock() + .expect("persistent typed store occupancy poisoned"); + occ.0 = occ.0.saturating_sub(stored_len); + occ.1 = occ.1.saturating_sub(1); + } + } + + /// Publish under the ceiling. At capacity the disposition is + /// `RefuseNewStore` (`std.cache_interface`): the publication is declined + /// and COUNTED -- never an unbounded write, and never a silent one. + pub fn put(&self, typed_key: &str, payload: &[u8]) { + let framed = encode_entry(typed_key, payload); + let framed_len = framed.len() as u64; + { + let occ = self + .occupancy + .lock() + .expect("persistent typed store occupancy poisoned"); + if occ.0.saturating_add(framed_len) > self.cap_bytes { + PERSIST_REFUSED_AT_CAPACITY.fetch_add(1, Ordering::SeqCst); + return; + } + } + let path = self.entry_path(typed_key); + if path.exists() { + return; + } + let Some(parent) = path.parent() else { + PERSIST_IO_ERROR.fetch_add(1, Ordering::SeqCst); + return; + }; + if std::fs::create_dir_all(parent).is_err() { + PERSIST_IO_ERROR.fetch_add(1, Ordering::SeqCst); + return; + } + // Write-then-rename: a reader never observes a partial entry, so a + // concurrent run's truncated write cannot become a wrong answer. + let temp = parent.join(format!( + "{typed_key}.{}.{}.tmp", + std::process::id(), + PERSIST_WRITE_BYTES.load(Ordering::SeqCst) + )); + if std::fs::write(&temp, &framed).is_err() { + PERSIST_IO_ERROR.fetch_add(1, Ordering::SeqCst); + let _ = std::fs::remove_file(&temp); + return; + } + if std::fs::rename(&temp, &path).is_err() { + PERSIST_IO_ERROR.fetch_add(1, Ordering::SeqCst); + let _ = std::fs::remove_file(&temp); + return; + } + PERSIST_WRITE_BYTES.fetch_add(payload.len(), Ordering::SeqCst); + let mut occ = self + .occupancy + .lock() + .expect("persistent typed store occupancy poisoned"); + occ.0 = occ.0.saturating_add(framed_len); + occ.1 = occ.1.saturating_add(1); + } +} + +fn source_root_namespace(source_roots: &[String]) -> String { + let mut sorted: Vec<&str> = source_roots.iter().map(|s| s.as_str()).collect(); + sorted.sort_unstable(); + sorted.dedup(); + let mut acc: u64 = 0xcbf2_9ce4_8422_2325; + for root in sorted { + for byte in root.as_bytes().iter().chain(std::iter::once(&0u8)) { + acc ^= u64::from(*byte); + acc = acc.wrapping_mul(0x1000_0000_01b3); + } + } + format!("roots-{acc:016x}") +} + +fn encode_entry(typed_key: &str, payload: &[u8]) -> Vec { + let key = typed_key.as_bytes(); + let mut out = Vec::with_capacity(PERSIST_MAGIC.len() + 4 + 4 + 8 + key.len() + payload.len()); + out.extend_from_slice(PERSIST_MAGIC); + out.extend_from_slice(&PERSIST_FORMAT_VERSION.to_le_bytes()); + out.extend_from_slice(&(key.len() as u32).to_le_bytes()); + out.extend_from_slice(&(payload.len() as u64).to_le_bytes()); + out.extend_from_slice(key); + out.extend_from_slice(payload); + out +} + +fn decode_entry(raw: &[u8], expect_key: &str) -> Result, String> { + let header = PERSIST_MAGIC.len() + 4 + 4 + 8; + if raw.len() < header { + return Err("entry shorter than its header".to_string()); + } + if &raw[0..PERSIST_MAGIC.len()] != PERSIST_MAGIC { + return Err("entry magic mismatch".to_string()); + } + let mut cursor = PERSIST_MAGIC.len(); + let version = u32::from_le_bytes(raw[cursor..cursor + 4].try_into().unwrap()); + cursor += 4; + if version != PERSIST_FORMAT_VERSION { + return Err(format!("entry format version {version} is not current")); + } + let key_len = u32::from_le_bytes(raw[cursor..cursor + 4].try_into().unwrap()) as usize; + cursor += 4; + let payload_len = u64::from_le_bytes(raw[cursor..cursor + 8].try_into().unwrap()) as usize; + cursor += 8; + if raw.len() != header + key_len + payload_len { + return Err("entry length disagrees with its declared parts".to_string()); + } + let stored_key = std::str::from_utf8(&raw[cursor..cursor + key_len]) + .map_err(|_| "entry key is not utf-8".to_string())?; + if stored_key != expect_key { + return Err("entry key does not match the key it was filed under".to_string()); + } + cursor += key_len; + Ok(raw[cursor..].to_vec()) +} + +fn scan_occupancy(namespace_root: &Path) -> (u64, u64) { + let mut bytes = 0u64; + let mut entries = 0u64; + let mut stack = vec![namespace_root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let Ok(read) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in read.flatten() { + let Ok(meta) = entry.metadata() else { continue }; + if meta.is_dir() { + stack.push(entry.path()); + } else if entry.path().extension().and_then(|s| s.to_str()) != Some("tmp") { + bytes = bytes.saturating_add(meta.len()); + entries += 1; + } + } + } + (bytes, entries) +} + +static PERSISTENT_STORE: std::sync::OnceLock> = + std::sync::OnceLock::new(); + +/// The process's persistent typed store for `source_roots`, or `None` when it +/// is not armed. +/// +/// OFF BY DEFAULT and armed only by `GUNBC_TYPED_STORE_PERSIST` naming a +/// directory. The SOURCE-ROOT SET is supplied by the caller's index rather than +/// read from the environment -- resolution is root-relative, so the roots are a +/// fact about the computation and never an operator's assertion about it. +/// +/// An open failure is reported once on stderr and leaves the store unarmed: the +/// run proceeds cold, which is the only honest arm, because no verdict may +/// depend on the store being available. A SECOND root set inside one process is +/// a different namespace and REFUSES rather than silently serving the first +/// one's entries. +pub fn persistent_typed_store_for( + source_roots: &[String], +) -> Result, String> { + let wanted = source_root_namespace(source_roots); + let store = PERSISTENT_STORE + .get_or_init(|| { + let root = std::env::var_os("GUNBC_TYPED_STORE_PERSIST")?; + let root = PathBuf::from(root); + if root.as_os_str().is_empty() { + return None; + } + let cap = std::env::var("GUNBC_TYPED_STORE_PERSIST_MAX_BYTES") + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|n| *n > 0) + .unwrap_or(PERSIST_DEFAULT_CAP_BYTES); + match PersistentTypedStore::open(&root, source_roots, cap) { + Ok(store) => Some(store), + Err(e) => { + eprintln!("[typed-store-persist] unarmed: {e}"); + None + } + } + }) + .as_ref(); + let Some(store) = store else { + return Ok(None); + }; + let opened = store + .namespace_root() + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + if opened != wanted { + return Err(format!( + "persistent typed store refused: this process opened the store for source-root \ + namespace '{opened}' and is now being asked for '{wanted}'. One process may not \ + serve two root sets from one store -- resolution is root-relative, so entries from \ + one namespace are not answers in the other" + )); + } + Ok(Some(store)) +} + +/// The store if this process already opened one. Used by the counters and the +/// receipt line, which must observe without arming. +pub fn persistent_typed_store_if_open() -> Option<&'static PersistentTypedStore> { + PERSISTENT_STORE.get().and_then(|s| s.as_ref()) +} + +#[doc(hidden)] +pub fn persistent_typed_store_is_armed_for_test() -> bool { + persistent_typed_store_if_open().is_some() +} + +/// One line naming what the persistent store did this run, for the floor's +/// measurement receipt. Live occupancy is reported beside throughput so the +/// ladder's residency obligation is discharged by a residency number. +pub fn persistent_typed_store_receipt_line() -> Option { + let store = persistent_typed_store_if_open()?; + let c = persistent_typed_store_counters_snapshot(); + Some(format!( + "[typed-store-persist] root={:?} hits={} misses={} rejected={} refused_at_capacity={} \ + io_errors={} read_bytes={} write_bytes={} occupancy_bytes={} occupancy_entries={} \ + cap_bytes={}", + store.namespace_root(), + c.persist_hit, + c.persist_miss, + c.persist_rejected, + c.persist_refused_at_capacity, + c.persist_io_error, + c.persist_read_bytes, + c.persist_write_bytes, + c.persist_occupancy_bytes, + c.persist_occupancy_entries, + store.cap_bytes(), + )) +} + +#[cfg(test)] +mod persistent_typed_store_tests { + use super::*; + + /// A temp directory that removes itself. The store is a filesystem + /// realization, so its discriminating evidence is a real directory, not a + /// supplied byte map: the properties under test (truncation, atomicity, + /// occupancy, the ceiling) are properties of files. + struct TempRoot(PathBuf); + + impl TempRoot { + fn new(tag: &str) -> Self { + let mut path = std::env::temp_dir(); + path.push(format!( + "gunbc-typed-store-test-{tag}-{}-{:?}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).expect("temp root"); + Self(path) + } + } + + impl Drop for TempRoot { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn roots() -> Vec { + vec!["dag".to_string(), "src/v2".to_string()] + } + + fn open(root: &TempRoot, cap: u64) -> PersistentTypedStore { + PersistentTypedStore::open(&root.0, &roots(), cap).expect("store opens") + } + + /// The positive control: a published entry is served back byte-identically + /// to a SECOND store object over the same directory -- which is what + /// "survives the process that computed it" means here. + #[test] + fn published_entry_is_served_to_a_later_store_over_the_same_directory() { + let root = TempRoot::new("roundtrip"); + let payload = b"typed-module-snapshot-bytes".to_vec(); + { + let writer = open(&root, 1 << 20); + writer.put("abc123", &payload); + assert_eq!(writer.occupancy().1, 1, "one entry resident after publish"); + } + let reader = open(&root, 1 << 20); + assert_eq!(reader.get("abc123"), Some(payload)); + assert_eq!( + reader.occupancy().1, + 1, + "a store opened over an existing directory counts what is already there" + ); + } + + /// The RED that makes the positive control mean something: a DIFFERENT key + /// is a miss, so a hit is evidence about the key and not about the + /// directory being non-empty. + #[test] + fn a_different_key_misses() { + let root = TempRoot::new("different-key"); + let store = open(&root, 1 << 20); + store.put("abc123", b"payload"); + assert_eq!(store.get("abc124"), None); + } + + /// A different SOURCE-ROOT set is a different namespace and reaches none of + /// the first one's entries. Resolution is root-relative, so serving across + /// that boundary would be a wrong answer, not a lucky hit. + #[test] + fn a_different_source_root_set_is_a_different_namespace() { + let root = TempRoot::new("namespace"); + let a = PersistentTypedStore::open(&root.0, &roots(), 1 << 20).expect("store a"); + a.put("abc123", b"payload"); + let b = + PersistentTypedStore::open(&root.0, &["dag".to_string()], 1 << 20).expect("store b"); + assert_eq!(b.get("abc123"), None); + assert_ne!(a.namespace_root(), b.namespace_root()); + } + + /// Truncation is REJECTED and COUNTED, never decoded. The absorbing arm + /// this forbids is a partial read served as a result (DESIGN section 5). + #[test] + fn a_truncated_entry_is_rejected_counted_and_removed() { + let root = TempRoot::new("truncated"); + let store = open(&root, 1 << 20); + store.put("abc123", b"a-payload-long-enough-to-truncate"); + let path = store.namespace_root().join("ab").join("abc123"); + let raw = std::fs::read(&path).expect("entry exists"); + std::fs::write(&path, &raw[..raw.len() - 5]).expect("truncate"); + + reset_persistent_typed_store_counters_for_test(); + assert_eq!(store.get("abc123"), None, "a truncated entry is not served"); + let counters = persistent_typed_store_counters_snapshot(); + assert_eq!(counters.persist_rejected, 1, "the rejection is counted"); + assert_eq!(counters.persist_miss, 1, "and reported as a miss"); + assert!( + !path.exists(), + "the bad entry is removed, not left to be re-read" + ); + } + + /// An entry filed under one key whose payload declares another is rejected. + /// This is the wall against a mis-filed or colliding entry silently + /// answering a question it is not an answer to. + #[test] + fn an_entry_whose_stored_key_disagrees_is_rejected() { + let root = TempRoot::new("key-mismatch"); + let store = open(&root, 1 << 20); + let framed = encode_entry("someone-elses-key", b"payload"); + let path = store.namespace_root().join("ab").join("abc123"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, framed).unwrap(); + + reset_persistent_typed_store_counters_for_test(); + assert_eq!(store.get("abc123"), None); + assert_eq!( + persistent_typed_store_counters_snapshot().persist_rejected, + 1 + ); + } + + /// An entry written under an older format version is not decoded. The + /// format version is the term that makes a codec change a miss rather than + /// garbage. + #[test] + fn a_stale_format_version_is_rejected() { + let root = TempRoot::new("format-version"); + let store = open(&root, 1 << 20); + let mut framed = encode_entry("abc123", b"payload"); + framed[PERSIST_MAGIC.len()..PERSIST_MAGIC.len() + 4] + .copy_from_slice(&(PERSIST_FORMAT_VERSION + 1).to_le_bytes()); + let path = store.namespace_root().join("ab").join("abc123"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, framed).unwrap(); + assert_eq!(store.get("abc123"), None); + } + + /// At the ceiling the disposition is RefuseNewStore: the publication is + /// declined and counted, occupancy does not grow past the ceiling, and the + /// run is merely cold. An unbounded write here is what the ladder's + /// RetentionUnbounded refusal exists to prevent. + #[test] + fn publication_at_the_ceiling_is_refused_and_counted() { + let root = TempRoot::new("capacity"); + let store = open(&root, 64); + reset_persistent_typed_store_counters_for_test(); + store.put("aa", &[0u8; 8]); + let after_first = store.occupancy(); + assert_eq!(after_first.1, 1, "the first small entry fits"); + store.put("bb", &[0u8; 4096]); + let counters = persistent_typed_store_counters_snapshot(); + assert_eq!( + counters.persist_refused_at_capacity, 1, + "the oversized publication is refused" + ); + assert_eq!(store.occupancy(), after_first, "and occupancy did not grow"); + assert_eq!(store.get("bb"), None, "the refused entry was never stored"); + assert!( + store.occupancy().0 <= store.cap_bytes(), + "occupancy stays under the declared ceiling" + ); + } + + /// Occupancy is LIVE RESIDENCY, not cumulative throughput. A throughput + /// counter cannot discharge the ladder's RetentionUnobserved obligation, so + /// this asserts the two move differently: a re-publication of an entry + /// already present adds no residency. + #[test] + fn occupancy_is_residency_and_not_throughput() { + let root = TempRoot::new("occupancy"); + let store = open(&root, 1 << 20); + reset_persistent_typed_store_counters_for_test(); + store.put("abc123", b"payload"); + let first = store.occupancy(); + store.put("abc123", b"payload"); + assert_eq!( + store.occupancy(), + first, + "re-publishing a resident entry does not grow residency" + ); + assert_eq!( + persistent_typed_store_counters_snapshot().persist_write_bytes, + b"payload".len(), + "and the throughput counter records only the one write that happened" + ); + } + + /// The realization's default ceiling is the modeled one. A literal here + /// disagreeing with `gunbc.floor_materialization` + /// `floor_typecheck_store_persist_cap_bytes` is two authorities for one + /// number (DESIGN section 3), so the test is the lockstep. + #[test] + fn persist_cap_matches_modeled_authority() { + assert_eq!( + persist_default_cap_bytes(), + 8_589_934_592, + "floor_typecheck_store_persist_cap_bytes in dag/gunbc/floor/floor_materialization.dag" + ); + } +} From 3408cee395a7dc9c0a7fffff9dbe69e6c63ed1da Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 04:34:20 +0000 Subject: [PATCH 2/5] Record the landed decisions, the outstanding measurement and the deletion trigger Co-Authored-By: Claude Opus 5 (1M context) --- .../typed-module-cross-run-materialization.md | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 docs/plans/typed-module-cross-run-materialization.md diff --git a/docs/plans/typed-module-cross-run-materialization.md b/docs/plans/typed-module-cross-run-materialization.md new file mode 100644 index 00000000000..7f9a34c5b2c --- /dev/null +++ b/docs/plans/typed-module-cross-run-materialization.md @@ -0,0 +1,95 @@ +# Typed-module materialization across runs + +> **Status:** IMPLEMENTED, UNMEASURED (2026-09-20). The declarations live in +> `gunbc.floor_materialization` (rows `floor_typecheck_store_persist_*`) and the realization in +> `v1_compiler.shared_typecheck_store` (`PersistentTypedStore`). This file carries only what +> neither of those can: the operating procedure for the measurement, the four operational choices +> and their reasons, and the deletion trigger. Nothing here restates a declaration — a number or a +> verdict quoted here would be a second authority for it. +> **Authority it answers to:** `DESIGN.md`; the ladder's admission rules in +> `std.materialization_ladder`; the retention vocabulary in `std.cache_interface`. +> **Framing:** relief during the v1 → v2 migration. Not a parallelism project. + +--- + +## 1. What this is + +The typed-module snapshots the in-process tiers already produce now have a host-local, +content-addressed backing, so the value survives the process that computed it. It is one ladder +rung — a second provider over the identity `union-typecheck-store(dag,src/v2)` that the in-process +provider already covers — and not a new cache: the byte transport, the content key and the seam +are the ones that were already there, and only the backing changed. + +## 2. What it delivers, and what it does not + +**Delivered: same-build, cross-process reuse.** Two processes running ONE built binary over one +corpus — the witness fold and the generated-artifact drift gate — each pay typecheck preparation +in full today. The second one now reads the first one's results. + +**Not delivered: cross-push reuse.** The typed-module content key's compiler-identity term is +`resolved_graph_cache::transform_content_digest()`, the content hash of the running binary, and +`src/v1/stage0/build.rs` embeds `GUNBC_BUILD_IDENTITY` — the checkout's commit — into that binary +through `cargo:rustc-env`. A rebuild at any new commit, INCLUDING a corpus-only `.dag` commit, +therefore re-keys every entry. Any claim that an unchanged module survives a push is unsupported +until compiler identity is derived from something invariant under a corpus-only commit, which is a +change to that derivation and not to this store. **The design this file replaces asserted that +relief; that assertion does not follow from the key it proposed, and is withdrawn.** + +The bound is also narrower than "preparation" within one run: a hit skips `collect_parent_envs` +and the typecheck compute, which is where the preparation wall is spent, but the module is still +parsed and resolved, because that is what derives the key. Parse is not relieved. + +## 3. The measurement that decides whether this was worth it + +Not yet run. It is three demonstrations, and the first two are what "working" means here: + +1. **Cold → warm, separate processes.** Run the fold and then the drift gate over one corpus under + one built binary with the store armed, against the same pair with it unarmed. Compare end-to-end + wall, peak RSS, and the fold's subject digest — which must be EQUAL, because a store that + changes a verdict is a defect and not a saving. Read the `[typed-store-persist]` line on the + floor receipt for hits, misses, rejections, refusals, throughput and LIVE OCCUPANCY. +2. **An invalidation control.** Edit one module in the corpus and confirm that module and its + dependents miss while the rest hit, and that no obsolete result is served. +3. **A failure control.** Point the store at an unwritable directory, truncate an entry, and fill + it past its ceiling. Each must produce a counted cold or refused path. A verification mismatch + must be reported as `persist_rejected`, never folded into an ordinary miss. + +The Rust-level evidence for (3) and for the store's own properties is +`persistent_typed_store_tests` in `v1_compiler.shared_typecheck_store`, including the ceiling +refusal, the truncation rejection, the key-mismatch rejection and the residency-versus-throughput +distinction. Those are unit-level; (1) and (2) are the production-path receipt and are outstanding. + +## 4. How to arm it + +Off by default. Three environment variables, read once per process: + +- `GUNBC_TYPED_STORE_PERSIST=` — the host-local directory. Arming is naming it. +- `GUNBC_TYPED_STORE_PERSIST_MAX_BYTES=` — overrides the modeled ceiling + (`floor_typecheck_store_persist_cap_bytes`). + +The SOURCE-ROOT SET is not an environment variable: it is taken from the index that asks, because +resolution is root-relative and the roots are a fact about the computation rather than an +operator's assertion about it. A second root set inside one process refuses. + +## 5. The four operational choices, and why + +| Choice | Settled as | Why | +|---|---|---| +| Location and ownership | One host-local directory named by the operator, outside the checkout. No daemon, no network service. | The tier is `ArtifactTier`/`LocalFilesystem` by `tier_axes`; anything more is a different provider with a different correctness surface. | +| Capacity and replacement | `ByteCapacity` with an exact limit, `RefuseNewStore`. | The ladder refuses `ReleasedNever` without a ceiling AND without observed occupancy, so both are obligations. `RefuseNewStore` over LRU because replacement is a second mechanism this bridge does not need: a full store declines, counts, and the run is merely cold. An entry-count bound over a variable-size payload establishes no byte bound. | +| Warm-seeding | Ordinary runs populate it. No seeding workflow. | A seeding job is a standing cost defended by a benefit nobody has measured yet. | +| Verification | The cold/warm procedure in §3, operator-invoked. | A scheduled job costs a full preparation by definition and is not a prerequisite for the first relief. | + +## 6. Deletion trigger + +This is a bridge over the v1 seed, and it ends with its consumer, not with a date: **when the +authoritative native route replaces the v1 preparation this store sits behind, its persistence +realization, its arming switch and its provider row are removed together.** v1 deletion does not +wait for this optimization to become a general platform. The rows are all named +`floor_typecheck_store_persist_*` in one module and the realization is one section of one file, so +the removal is a deletion rather than an untangling. + +## 7. Non-goals + +Threading the resolver, re-sharding the fold, and moving the fold to a hosted runner are all out +of scope. This change removes repeated work rather than distributing it. From e819355817a83140e1f19f69d27126bf5b8ae784 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 05:04:25 +0000 Subject: [PATCH 3/5] An unopenable armed store is a typed refusal, not an eprintln and a cold run clippy::disallowed_macros forbids eprintln in this library module, and the note names the right repair rather than an allow: a structured return. That forced the better arm too. UNARMED and ARMED-BUT-UNUSABLE were being collapsed into one silent cold path; they are different states. No environment variable means the operator asked for no store and cold is correct. A directory that was NAMED and cannot be opened is an operator asking for a store they do not have, so it now stops the line with a located diagnostic instead of running cold under a name that promised otherwise. Co-Authored-By: Claude Opus 5 (1M context) --- src/v1/stage0/src/shared_typecheck_store.rs | 66 ++++++++++++--------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/src/v1/stage0/src/shared_typecheck_store.rs b/src/v1/stage0/src/shared_typecheck_store.rs index 764b87c3cdf..c7adffe2e4d 100644 --- a/src/v1/stage0/src/shared_typecheck_store.rs +++ b/src/v1/stage0/src/shared_typecheck_store.rs @@ -570,7 +570,7 @@ fn scan_occupancy(namespace_root: &Path) -> (u64, u64) { (bytes, entries) } -static PERSISTENT_STORE: std::sync::OnceLock> = +static PERSISTENT_STORE: std::sync::OnceLock, String>> = std::sync::OnceLock::new(); /// The process's persistent typed store for `source_roots`, or `None` when it @@ -581,36 +581,43 @@ static PERSISTENT_STORE: std::sync::OnceLock> = /// read from the environment -- resolution is root-relative, so the roots are a /// fact about the computation and never an operator's assertion about it. /// -/// An open failure is reported once on stderr and leaves the store unarmed: the -/// run proceeds cold, which is the only honest arm, because no verdict may -/// depend on the store being available. A SECOND root set inside one process is -/// a different namespace and REFUSES rather than silently serving the first -/// one's entries. +/// UNARMED and ARMED-BUT-UNUSABLE are different states with different arms. An +/// absent environment variable means the operator asked for no store, so the +/// run proceeds cold and that is correct: no verdict may depend on a store +/// being available. A directory that was NAMED and cannot be opened is an +/// operator asking for a store they do not have, so it REFUSES with a located +/// diagnostic rather than quietly running cold under a name that promised +/// otherwise (DESIGN section 5). A SECOND root set inside one process refuses +/// for the same reason: entries from one namespace are not answers in another. pub fn persistent_typed_store_for( source_roots: &[String], ) -> Result, String> { let wanted = source_root_namespace(source_roots); - let store = PERSISTENT_STORE - .get_or_init(|| { - let root = std::env::var_os("GUNBC_TYPED_STORE_PERSIST")?; - let root = PathBuf::from(root); - if root.as_os_str().is_empty() { - return None; - } - let cap = std::env::var("GUNBC_TYPED_STORE_PERSIST_MAX_BYTES") - .ok() - .and_then(|raw| raw.trim().parse::().ok()) - .filter(|n| *n > 0) - .unwrap_or(PERSIST_DEFAULT_CAP_BYTES); - match PersistentTypedStore::open(&root, source_roots, cap) { - Ok(store) => Some(store), - Err(e) => { - eprintln!("[typed-store-persist] unarmed: {e}"); - None - } - } - }) - .as_ref(); + let opened = PERSISTENT_STORE.get_or_init(|| { + let Some(root) = std::env::var_os("GUNBC_TYPED_STORE_PERSIST") else { + return Ok(None); + }; + let root = PathBuf::from(root); + if root.as_os_str().is_empty() { + return Ok(None); + } + let cap = std::env::var("GUNBC_TYPED_STORE_PERSIST_MAX_BYTES") + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|n| *n > 0) + .unwrap_or(PERSIST_DEFAULT_CAP_BYTES); + PersistentTypedStore::open(&root, source_roots, cap).map(Some) + }); + let store = match opened { + Ok(store) => store.as_ref(), + Err(e) => { + return Err(format!( + "persistent typed store refused: GUNBC_TYPED_STORE_PERSIST named a store this \ + process cannot open ({e}). Arming names a directory, so an unopenable one is a \ + stopped line, not a quiet cold run" + )) + } + }; let Some(store) = store else { return Ok(None); }; @@ -633,7 +640,10 @@ pub fn persistent_typed_store_for( /// The store if this process already opened one. Used by the counters and the /// receipt line, which must observe without arming. pub fn persistent_typed_store_if_open() -> Option<&'static PersistentTypedStore> { - PERSISTENT_STORE.get().and_then(|s| s.as_ref()) + PERSISTENT_STORE + .get() + .and_then(|s| s.as_ref().ok()) + .and_then(|s| s.as_ref()) } #[doc(hidden)] From 9482a7b6bb1c3d0d0874e974995f5d04b011f132 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 06:27:01 +0000 Subject: [PATCH 4/5] Say why this is not a fork of resolved_graph_cache Both are per-host content-addressed stores, so a reviewer is right to ask why there are two. They key different computations at different grains, and they share one compiler-identity authority rather than each deriving its own. Co-Authored-By: Claude Opus 5 (1M context) --- dag/gunbc/floor/floor_materialization.dag | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dag/gunbc/floor/floor_materialization.dag b/dag/gunbc/floor/floor_materialization.dag index 423a8bf74da..ce07263826f 100644 --- a/dag/gunbc/floor/floor_materialization.dag +++ b/dag/gunbc/floor/floor_materialization.dag @@ -217,6 +217,16 @@ fn floor_typecheck_store_materialization_without_provider() -> ValueMaterializat // (LocalFilesystem) — the same axis reading that puts extdeps.realization.resolved_graph's // per-host content-addressed store at ArtifactTier. // +// NOT A SECOND STORE BESIDE extdeps.realization.resolved_graph.resolved_graph_cache. That store is +// content-addressed and per-host too, and the question "why two" is the right one to ask. They key +// DIFFERENT COMPUTATIONS: resolved_graph_cache keys a whole entry's resolved graph by its closure +// subject, and this row keys ONE TYPED MODULE by its own content. The grains are not +// interchangeable — a one-module edit invalidates a whole closure subject and only that module and +// its dependents here, which is the entire reason a per-module grain buys anything the whole-graph +// grain does not. They also share the compiler-identity term rather than each deriving one +// (resolved_graph_cache transform_content_digest), which is the fnv1a64 dual-surface discipline +// applied: two providers, one identity authority, no second key scheme. + // WHAT THIS DELIVERS TODAY, named so the row is not read as more than it is: the typed-module // content key's compiler-identity term is the running binary's content digest, and build.rs embeds // the checkout's commit in that binary, so a rebuild at ANY new commit — including a corpus-only From 799c4530b93875f7e302ab2831113fb3b614c0f2 Mon Sep 17 00:00:00 2001 From: gunbc-ci-auto-heal Date: Sun, 20 Sep 2026 07:51:49 +0000 Subject: [PATCH 5/5] Cover the arm that decides cold-vs-refuse for an unusable armed store The clippy repair changed an armed-but-unopenable store from a silent cold run into a located refusal. That is the arm that carries the promise made by naming a directory, and it had no discriminating test. Co-Authored-By: Claude Opus 5 (1M context) --- src/v1/stage0/src/shared_typecheck_store.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/v1/stage0/src/shared_typecheck_store.rs b/src/v1/stage0/src/shared_typecheck_store.rs index c7adffe2e4d..b61ce00bc9c 100644 --- a/src/v1/stage0/src/shared_typecheck_store.rs +++ b/src/v1/stage0/src/shared_typecheck_store.rs @@ -869,6 +869,24 @@ mod persistent_typed_store_tests { ); } + /// A named directory that cannot be opened is an ERROR, not an empty + /// store. This is the arm that decides whether an armed-but-unusable store + /// runs cold or stops the line, and `persistent_typed_store_for` turns this + /// `Err` into a located refusal: the operator named a store, so silently + /// proceeding without one would be a promise quietly broken. + #[test] + fn a_root_that_cannot_be_opened_is_an_error_and_not_an_empty_store() { + let root = TempRoot::new("unopenable"); + // A regular FILE standing where the store's directory must be created. + let blocked = root.0.join("not-a-directory"); + std::fs::write(&blocked, b"occupied").expect("write blocker"); + let opened = PersistentTypedStore::open(&blocked, &roots(), 1 << 20); + assert!( + opened.is_err(), + "an unopenable root must refuse, not report an empty store" + ); + } + /// The realization's default ceiling is the modeled one. A literal here /// disagreeing with `gunbc.floor_materialization` /// `floor_typecheck_store_persist_cap_bytes` is two authorities for one