diff --git a/dag/gunbc/floor/floor_materialization.dag b/dag/gunbc/floor/floor_materialization.dag index 1488b9475cf..ce07263826f 100644 --- a/dag/gunbc/floor/floor_materialization.dag +++ b/dag/gunbc/floor/floor_materialization.dag @@ -206,3 +206,177 @@ 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. +// +// 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 +// .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/docs/plans/typed-module-cross-run-materialization.md b/docs/plans/typed-module-cross-run-materialization.md index af4b54c93be..97d6e3f02e2 100644 --- a/docs/plans/typed-module-cross-run-materialization.md +++ b/docs/plans/typed-module-cross-run-materialization.md @@ -1,278 +1,101 @@ # Typed-module materialization across runs -> **Status:** DESIGN UNDER REVIEW (2026-09-20). Hand-authored, **not a projection**: no -> `gunbc.generated_artifact` row produces this file. On acceptance its declarations move into -> `gunbc.floor_materialization` (+ `std.materialization_ladder` if the frame lands there) and this -> file is replaced by that module's projection or deleted. -> **Authority it answers to:** `docs/dag-modeling/DESIGN.md`; the ladder's own admission rules in -> `std.materialization_ladder`. -> **Framing:** relief during the v1 → v2 migration. Not a parallelism project — see §7. +> **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. +> **Supersedes the design landed in #11779**, which this file replaced in place, as that design's +> own status line prescribed for acceptance. Three of its claims did not survive implementation and +> are withdrawn rather than quietly dropped: the `SharedStateFrame` and `CasTier` choices (§5), the +> statement that no content key existed, and the cross-push relief its key could not deliver (§2). +> Its projected figures — 8.3 min → ~1 min, 32 min → ~20 min per push — were never measurements and +> are not carried forward; §3 is what would establish them. --- -## 1. The measurement this exists to answer +## 1. What this is -Required CI per push, after the 2026-09-19 consolidation (one job, `witnesses`): +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. -| Step | Wall | Threads | -|---|---|---| -| Build compiler + witness executor | 2.9 min | parallel (cargo) | -| clippy | 1.4 min | parallel (cargo) | -| Nominal witnesses fold | 21 min | **1** | -| Generated-artifact drift gate | 6.3 min | **1** | - -Inside the fold (local run, nominal roster, 6,242 files, 1,733-module closure): - -| Phase | Wall | -|---|---| -| parse (.dag: src/v1, dag, src/v2) | ~2 min | -| namespace-wave admission | ~1.7 min | -| gate closure (×2) | 0.6 min | -| **strict preparation — resolve 1,733 modules** | **8.3 min** | -| discovery authority | 1.6 min | -| site projection | 1.6 min | -| reach probes + 400 claims | ~2 min (the claims themselves: 53 s) | - -Peak RSS: fold 20.2 GiB, drift gate 12.9 GiB. CPU time equals wall time in every phase. - -**The cost is preparation, not claims, and it is paid again on every process.** The drift gate is a -second process over the same corpus, so it pays a second time. Nothing survives exit: the floor's -teardown line names `process_resolve_index`, `process_resolve_store` and -`compile_dag_rust_emit_check_memo` being dropped. - -## 2. Why now, and why it is framed at v1 → v2 - -The fold resolves `dag` **and** `src/v2` as one namespace, because the migration means both trees -are live at once. That is what makes the closure 1,733 modules and preparation 8.3 minutes. A -session PR typically changes a handful of `.dag` modules in that corpus, and every run re-resolves -the rest from scratch. - -So the relief is structural to the migration rather than incidental to it: while two corpora are -resolved together, the unchanged one is re-derived on every push of the other. - -## 3. What already exists (this design adds one rung, it does not build a cache) - -- **`gunbc.floor_materialization`** declares two providers, both scoped to the executor process - with `scope_released_retention`: - - `process_shared_index` over `union-index-build(dag,src/v2)` - - `process_shared_typecheck_store` over `union-typecheck-store(dag,src/v2)` -- **`std.materialization_ladder`** already carries the vocabulary this needs: - `Frame { name, kind }`, `ProviderTier` including `CasTier { keying: ContentKeyed }`, - and **`persistent_retention(capacity)`** (`release_policy: ReleasedNever`). -- **`src/v1/stage0/src/shared_typecheck_store.rs`** ("S2a increment C") is a working **serde byte - transport** for typed modules. It serializes `TypecheckModuleResult` as *authored module and type - names*, explicitly so a decoding process can read an encoding process's snapshot against its own - intern table. It was built for cross-worker sharing; cross-run is the same problem with a longer - gap. -- **`SharedTypecheckStoreCounters`** already counts hits, misses and bytes. -- **`test.claim.typed_module_cache_capacity_witness`** already guards the cache's capacity. +## 2. What it delivers, and what it does not -What does *not* exist: any retention beyond process exit, and any content key. +**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. -## 4. The design +**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.** -### 4.1 One new frame +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. -``` -data floor_runner_host_frame: Frame = Frame { - name: "runner-host", - kind: UnboundedSiblingsFrame -} -``` +## 3. The measurement that decides whether this was worth it -**The kind is load-bearing, and the first draft had it wrong** (review of this design, 2026-09-20). -`SharedStateFrame` would have defeated the whole row. The ladder's plurality rules read: +Not yet run. It is three demonstrations, and the first two are what "working" means here: -> (1) Redundancy visible in the graph whose LCA frame is shared-state (one workspace/process — -> rewireable) = AUTHORED duplication = error; the fix is rewire/Share, **never a cache**. … -> (3) EXPECTED-emergent redundancy — a single demand today, but an enclosing frame declares -> re-demand (`ReplayedFrame` attempts>1 = retry/restart replay; **`UnboundedSiblingsFrame` = server -> loop / recurring CI invocations over time**) — obligates UP FRONT: prepare-before-demand, so -> checkpointing and **cross-run caches are derived, not bolted on** after the incident. +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. -Successive CI invocations are state (3), not state (1): they execute apart by construction and -cannot be rewired into one another. Declared as `SharedStateFrame` the ladder would classify them as -authored duplication and answer that a cache is the wrong fix; declared as `UnboundedSiblingsFrame` -it derives the obligation this row exists to discharge. The frame is broader than -`floor_executor_process_frame()` and narrower than the fleet — a host's store is never read by -another host, so no cross-host coherence question arises. +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.2 One new provider row +## 4. How to arm it -``` -data floor_typecheck_store_persist_provider: CacheProvider = provider_row( - id: "host_persisted_typecheck_store", - scope: [floor_runner_host_frame], - coverage: CoversIdentities { identities: [floor_typecheck_store_identity] }, - tier: ArtifactTier { keying: ContentKeyed }, - retention: persistent_retention(capacity: CapacityBounded { - limit: , - at_capacity: - }) -) -``` +Off by default. Three environment variables, read once per process: -It covers the **same identity** as the in-process provider, `union-typecheck-store(dag,src/v2)`. -The in-process row stays exactly as it is; this row says where the value lives when the process -that computed it is gone. +- `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`). -**`ArtifactTier`, not `CasTier`** (review, 2026-09-20). `tier_axes` grounds the tiers in placement: -`ArtifactTier` is `LocalFilesystem` and `CasTier` is `RemoteNetwork`. The implementation here is a -per-host directory, so `CasTier` would have declared a placement nothing implements and contradicted -this row's own no-cross-host boundary. Both tiers are `KeyAddressed`, so the content keying below is -unchanged. A fleet-wide store, if it is ever wanted, is the `CasTier` row — a different provider with -a coherence question this one does not have. +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. -**The ladder forces two properties, and that is the point of modelling it here rather than adding a -disk cache:** `retention_admission` refuses `ReleasedNever` with `CapacityUnbounded` -(`RetentionUnbounded`) and with `CapacityUnobserved` (`RetentionUnobserved`). A persistent provider -is therefore inadmissible unless it is **bounded** and its occupancy is **observed**. Both are -obligations this design must discharge, not features it may skip. +## 5. The four operational choices, and why -### 4.3 Why content keying is sound here - -`FrameDemand.nature` for typed-module resolution is `PureComputation`: the same module text, the -same dependency closure and the same compiler produce the same typed result. A `ContentKeyed` -reuse of a pure computation is sound by construction, which is the argument the ladder wants — no -staleness envelope is being declared, because nothing is being read from the world. - -The key is therefore everything the computation reads: - -| Component | Why it is in the key | What breaks if it is out | +| Choice | Settled as | Why | |---|---|---| -| Module authored identity | names the value | wrong module served | -| Digest of the module's own source bytes | the text typechecked | edits ignored | -| Digests of its resolved dependencies (transitive, as resolved) | typing depends on them | a dependency edit serves a stale type | -| Compiler identity — the `gunbc`/`claim_executor` binary digest | the typechecker *is* an input | a typechecker fix serves pre-fix types | -| Source-root set (`dag`, `src/v2`, …) | resolution is root-relative | a root change serves the wrong resolution | -| Store format version | decode compatibility | a format change decodes garbage | - -### 4.4 Where it attaches in the code - -**The first draft's "reuse the existing seam" does not reach the fold, and that was the design's -worst error** (review, 2026-09-20). The shared store is armed only when the adaptive worker width -exceeds one, and `floor_materialization` says width 1 keeps the private per-index `Rc` cache. The -read path says it plainly: +| 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. | -```rust -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()); -}; -``` - -The nominal fold runs at width 1. So every read it performs takes that fallback — there is even a -counter for it — and a persistent backing behind the shared store would never be consulted or -populated. No warm relief could occur. - -**So the store must be armed independently of worker width**, and that is a change to arming, not -only to backing: - -| | Today at width 1 | This design | -|---|---|---| -| Read | private `Rc` map | L1 private `Rc` map, then L2 persistent entry on an L1 miss | -| Write | private `Rc` map only | L1 as today, plus one L2 encode per newly resolved module | -| Arming | `scheduled_width > 1` | independent of width; the persistent provider is its own arm | +## 6. Deletion trigger -**Two levels, not one, and the store's "sole authority" rule has to relax.** The cross-worker store -documents that when armed it becomes the only typed-cache authority and `index_insert_typed` stops -writing the per-index map, so reads decode bytes — which avoids double retention but pays a decode -on **every** read. At width 1 that would replace a cheap `Rc` clone with a decode on thousands of -reads and could make a warm run *slower* than a cold one. The persistent provider therefore sits -BEHIND the in-process map rather than replacing it: L1 answers repeat reads within a run, L2 answers -the first read of a module that a previous run resolved. - -**The serialization cost is a first-class unknown, not an assumption.** Width 1 encodes nothing -today, so this design adds encode work that did not exist: one encode per newly resolved module and -one decode per warm hit. Step 3 of the rollout measures exactly this — cold-run wall time with the -provider armed but empty (encode cost, no benefit) against cold-run time with it disarmed, and warm -against cold. If the encode cost on a cold run exceeds the warm saving on a typical PR, the design -fails its own test and the provider stays disarmed. - -The counters already in `SharedTypecheckStoreCounters` become the observation the ladder demands, -emitted into the floor's measurement receipt so a run reports hits, misses, bytes and evictions — -and, with the above, L1 hits separately from L2 hits. - -## 5. Soundness, and how each failure mode ends - -| Failure | Behaviour | -|---|---| -| Entry decodes wrongly / truncated | treated as a **miss**, recompute, count it. Never a partial decode used as a result. | -| Key collision | keyed by the repo's existing content hash; a collision is a hash break, not a cache bug. | -| Compiler changed | binary digest is in the key, so every entry misses. Correct, and honest: a `src/v1` Rust change gets no relief. | -| Corpus module changed | that module and its dependents miss; the rest hit. This is the common session PR. | -| Store corrupt / unreadable / disk full | fall back to full preparation, log it, continue. The gate's verdict never depends on the store being available. | -| A stale hit would change a verdict | §6 verify mode is the falsifier. | - -## 6. The falsifier - -A `verify` mode resolves a module **both** ways — from the store and from scratch — and refuses if -the typed results differ. It runs in a scheduled job, not per push, because it costs a full -preparation by definition. Without it, "the cache is sound" is a claim with no discriminating test, -which DESIGN §5 names spec-without-execution. - -**The subject digest cannot serve as that check** (review, 2026-09-20). `PreparedSubject.subject_digest` -is computed by `subject_digest_for_closure` BEFORE strict resolution, over the closure's source -content and the compiler transform content — it hashes the *inputs*. Warm and cold runs of one commit -therefore print the same digest even if the store returned a wrong typed result, so comparing it -would be a check that cannot fail for the reason it is written: precisely the -spec-without-execution shape DESIGN section 5 forbids. - -The CI check must compare **outputs**: -- a digest over the typed results of the resolved closure (the fold already holds every - `TypecheckModuleResult` it produced), emitted into the measurement receipt; and -- the floor's existing receipts, which are output artifacts — `required_floor_disposition.tsv`, - `required_floor_claim_cost.tsv`, `required_floor_cross_claim_demand.tsv`. A warm run and a cold - run of one commit must produce byte-identical dispositions and per-claim verdicts. - -The first is the direct falsifier; the second is available today with no new emission and would -catch any divergence that changes a claim's disposition. +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.** The interpreter is `Rc`-bound: 13,313 `Rc<` against 40 `Arc<` in - `src/v1`, and even `parse_source` returns `Rc`. `shared_typecheck_store.rs` names - that conversion as its own dissolve-on ("store-path `Rc`→`Arc` on `TypecheckModuleResult`"). - Out of scope here, and not needed: this design removes the work rather than distributing it. -- **Re-sharding the fold across workers or processes.** Ruled out by the fold's own authority - ("the single prepared subject is precisely what the fold exists to be"), and it would multiply - preparation rather than divide it. Note the existing `CONTROLLED_WIDTH: usize = 2` sharded path - in `run_discovery_corpus_with_options` belongs to the corpus-batch regime whose authority was - superseded 2026-08-13 and deleted 2026-08-15. -- **Moving the fold to a hosted runner.** It peaks at 20.2 GiB; a standard hosted runner has 16 GB. - -## 8. What the reviewer has to decide - -1. **Store location.** A per-host directory (candidate: alongside the existing - `/var/lib/ctrl/…` host state), and whether ctrl's `ctrl-runner-reclaim.timer` prunes it or the - store evicts for itself. -2. **The capacity limit and the at-capacity policy.** The ladder refuses the provider without them. - Proposal: entry-count bound sized to one corpus plus headroom, least-recently-used eviction. -3. **Warm-seeding.** Whether a post-merge run on `main` populates the store so PR runs start warm, - or PRs warm it themselves. -4. **Whether verify mode is a scheduled job** (§6) or an operator-invoked instrument. -5. **Whether the width-1 arming is acceptable as its own provider arm** (§4.4), given it adds encode - work to every cold run. The alternative is to arm only where a warm store already exists for the - closure, which is cheaper on a first run and more state to reason about. - -## 9. Rollout, and the relief each step buys - -| Step | Work | Effect | -|---|---|---| -| 1. Model rows: frame, provider, capacity, eviction, counters-as-observation | today | the authority exists; ladder admission passes or refuses loudly | -| 2. Persistent backing behind `GUNBC_TYPED_STORE_PERSIST`, default **off** | today | nothing changes until armed | -| 3. Measure on one host: cold armed-but-empty vs cold disarmed (the encode cost), then warm vs cold, comparing typed-output digest and the floor's receipts | tomorrow | the number this design is worth, and whether the encode cost eats it | -| 4. Arm in the workflow env | tomorrow | the relief lands in CI | -| 5. Widen `required_gate_prefixes` back toward the full roster | after | the claims dropped for cost come back | - -**Expected relief, on the measured basis** (a PR touching `.dag` corpus modules only): - -| | Now | With a warm store | -|---|---|---| -| Strict preparation | 8.3 min | ~1 min | -| Drift gate (same corpus work) | 6.3 min | ~2 min | -| Fold total | 21 min | ~12 min | -| Push total | ~32 min | ~20 min | - -A PR that changes the v1 Rust compiler gets **no** relief, by §4.3. During v1 → v2 the bulk of the -churn is `.dag` corpus, which is exactly the case that hits. +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. diff --git a/src/v1/stage0/src/cli_run.rs b/src/v1/stage0/src/cli_run.rs index 3fde45cfc2d..c13e210ae10 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..b61ce00bc9c 100644 --- a/src/v1/stage0/src/shared_typecheck_store.rs +++ b/src/v1/stage0/src/shared_typecheck_store.rs @@ -232,3 +232,671 @@ 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, String>> = + 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. +/// +/// 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 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); + }; + 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().ok()) + .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" + ); + } + + /// 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 + /// 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" + ); + } +}