diff --git a/dag/gunbc/floor_memory_instrumentation_seed_growth.dag b/dag/gunbc/floor_memory_instrumentation_seed_growth.dag index 6055e067905..7a6a72f2c82 100644 --- a/dag/gunbc/floor_memory_instrumentation_seed_growth.dag +++ b/dag/gunbc/floor_memory_instrumentation_seed_growth.dag @@ -24,6 +24,11 @@ data floor_memory_instrumentation_seed_growth_justification: SeedGrowthJustifica DeclarationRef { module_path: "v1_compiler.cli_run.floor_memory_supervisor", decl_name: "beat_field", field: WholeDeclaration }, DeclarationRef { module_path: "v1_compiler.cli_run.floor_memory_supervisor", decl_name: "transcribe_floor_beats", field: WholeDeclaration }, DeclarationRef { module_path: "v1_compiler.target_invocation_host", decl_name: "floor_phase_attribution_rendered", field: WholeDeclaration }, + DeclarationRef { module_path: "v1_compiler.cli_run.required_floor_runner", decl_name: "WarmFrameSlot", field: WholeDeclaration }, + DeclarationRef { module_path: "v1_compiler.cli_run.required_floor_runner", decl_name: "order_warm_modules", field: WholeDeclaration }, + DeclarationRef { module_path: "v1_compiler.cli_run.required_floor_runner", decl_name: "floor_warm_row_identity", field: WholeDeclaration }, + DeclarationRef { module_path: "v1_compiler.v1_interpreter", decl_name: "CROSS_CLAIM_LAST_STORE_DIGEST", field: WholeDeclaration }, + DeclarationRef { module_path: "v1_compiler.v1_interpreter", decl_name: "take_cross_claim_store_digest", field: WholeDeclaration }, DeclarationRef { module_path: "v1_compiler.cli_run.entry_resolve", decl_name: "TypedModuleClass", field: WholeDeclaration }, DeclarationRef { module_path: "v1_compiler.cli_run.entry_resolve", decl_name: "ExclusiveBytesReading", field: WholeDeclaration }, DeclarationRef { module_path: "v1_compiler.cli_run.entry_resolve", decl_name: "typed_module_class_exclusive_bytes", field: WholeDeclaration }, @@ -31,8 +36,8 @@ data floor_memory_instrumentation_seed_growth_justification: SeedGrowthJustifica DeclarationRef { module_path: "v1_compiler.target_invocation_host", decl_name: "run_typed_graph_exclusive_bytes_floor_subject", field: WholeDeclaration }, DeclarationRef { module_path: "v1_compiler.target_invocation_host", decl_name: "typed_graph_exclusive_bytes_over", field: WholeDeclaration } ], - reason: "WHY RUST: the subjects are host objects the modeled substrate cannot hold. The census and the byte attribution read the resolver's typed graph -- TypedModule, TypeEnv and their Rc identity -- which exists only as host values while the v1 seed typechecks; no .dag carrier names a TypedModule or an allocation, and the allocator's live bytes (glibc mallinfo2) are a host reading with no modeled observation. The beat transcription reads a child's stderr and lifts decimal text to integers, which the interpreter cannot do (it registers string_contains and string_length only, gunbc.observation_emit_census). WHAT IS IN .dag: every judgment the instrumentation makes -- which seam a token is, what the held set is, where a phase begins, what a reading refuses on -- is gunbc.floor_demand floor_phase_attribution; the Rust reads and transcribes. prepared_graph_without_typecheck_caches is the one change that is not instrumentation: it ends a typecheck cache's lifetime at the strict resolve's return, a §2 lifetime fix on the floor's route. WHY ADMITTED AGAINST THE v1 FREEZE (gunbc.v1_maintenance_standing v1_seed_standing): by PURPOSE -- it measures and trims the required floor's memory on the v2 self-host path. It adds no language behaviour, no compatibility obligation, no escape hatch or admission row, and no capability the seed lacked; the floor's verdicts are unchanged, and the instrumentation only reads and frees what the floor frees anyway.", + reason: "WHY RUST: the subjects are host objects the modeled substrate cannot hold. The census and the byte attribution read the resolver's typed graph -- TypedModule, TypeEnv and their Rc identity -- which exists only as host values while the v1 seed typechecks; no .dag carrier names a TypedModule or an allocation, and the allocator's live bytes (glibc mallinfo2) are a host reading with no modeled observation. The beat transcription reads a child's stderr and lifts decimal text to integers, which the interpreter cannot do (it registers string_contains and string_length only, gunbc.observation_emit_census). WHAT IS IN .dag: every judgment the instrumentation makes -- which seam a token is, what the held set is, where a phase begins, what a reading refuses on -- is gunbc.floor_demand floor_phase_attribution; the Rust reads and transcribes. WarmFrameSlot is a lifetime change on the same route: the pure-producer warm holds one module frame at a time instead of every producer module frame at once, so freed frames stop growing the allocator arena that set the floor peak; floor_warm_row_identity and the store digest it reads (CROSS_CLAIM_LAST_STORE_DIGEST, take_cross_claim_store_digest) print one (producer, portable digest) line per warmed producer, the producer-grain differential between floor runs. prepared_graph_without_typecheck_caches is the one change that is not instrumentation: it ends a typecheck cache's lifetime at the strict resolve's return, a §2 lifetime fix on the floor's route. WHY ADMITTED AGAINST THE v1 FREEZE (gunbc.v1_maintenance_standing v1_seed_standing): by PURPOSE -- it measures and trims the required floor's memory on the v2 self-host path. It adds no language behaviour, no compatibility obligation, no escape hatch or admission row, and no capability the seed lacked; the floor's verdicts are unchanged, and the instrumentation only reads and frees what the floor frees anyway.", owning_dissolution_lane: "v1-hand-queue-drain" as RoadmapNodeId, - trigger: "Two capabilities, each retiring its half. (1) The census and byte-attribution half (entry_resolve and required_floor_runner declarations) deletes when the typed graph the floor prepares is a VALUE a .dag fold holds -- the v2 self-hosted typecheck producing its typed modules in the substrate -- together with a modeled allocator observation (a live-bytes reading bound to its host realization), so that the census and attribution become folds over that value and the allocator reading becomes an observation, not a host call. A rewrite that keeps the walk in Rust behind a .dag wrapper does not satisfy it. The leave-one-out half (TypedModuleClass, ExclusiveBytesReading, typed_module_class_exclusive_bytes and the two typed-graph-exclusive-bytes host producers) retires with (1): its meaning is already gunbc.typed_graph_exclusive_bytes, and the host part is the same drop-and-read over the same host value. (2) The transcription half (floor_memory_supervisor FloorBeatLine through transcribe_floor_beats, and floor_phase_attribution_rendered) deletes on the trigger already declared at gunbc.floor_demand floor_beat_reading_primitive_boundary: the interpreter's argument surface admitting a constructed std.measure value, plus string parsing sufficient to read a beat line, so the fold reads the heartbeat directly. prepared_graph_without_typecheck_caches dissolves when the typed module no longer carries a typecheck-time cache past its importers' typecheck, which is a representation fact the per-module TypeEnv design owns.", - current_boundary: "src/v1/stage0/src/cli_run/entry_resolve.rs (floor_retention_census, wiring_identity, process_resolve_store_graphs, the byte attribution and its arming); src/v1/stage0/src/cli_run/required_floor_runner.rs (floor_retained_census, its seam calls, the parse-index handover and the fold's last-scope drop); src/v1/stage0/src/cli_run.rs (prepared_graph_without_typecheck_caches); src/v1/stage0/src/cli_run/floor_memory_supervisor.rs (stderr tee and beat transcription); src/v1/stage0/src/target_invocation_host.rs (floor_phase_attribution_rendered); dag/gunbc/floor_memory_instrumentation_seed_growth.dag" + trigger: "Two capabilities, each retiring its half. (1) The census and byte-attribution half (entry_resolve and required_floor_runner declarations) deletes when the typed graph the floor prepares is a VALUE a .dag fold holds -- the v2 self-hosted typecheck producing its typed modules in the substrate -- together with a modeled allocator observation (a live-bytes reading bound to its host realization), so that the census and attribution become folds over that value and the allocator reading becomes an observation, not a host call. A rewrite that keeps the walk in Rust behind a .dag wrapper does not satisfy it. The leave-one-out half (TypedModuleClass, ExclusiveBytesReading, typed_module_class_exclusive_bytes and the two typed-graph-exclusive-bytes host producers) retires with (1): its meaning is already gunbc.typed_graph_exclusive_bytes, and the host part is the same drop-and-read over the same host value. (2) The transcription half (floor_memory_supervisor FloorBeatLine through transcribe_floor_beats, and floor_phase_attribution_rendered) deletes on the trigger already declared at gunbc.floor_demand floor_beat_reading_primitive_boundary: the interpreter's argument surface admitting a constructed std.measure value, plus string parsing sufficient to read a beat line, so the fold reads the heartbeat directly. WarmFrameSlot dissolves on its frontier row in gunbc.resolver_cost_frontier (the demand-sized frame consuming the declarer pool of PR-2); the warm-row digest line dissolves with the census half above. prepared_graph_without_typecheck_caches dissolves when the typed module no longer carries a typecheck-time cache past its importers' typecheck, which is a representation fact the per-module TypeEnv design owns.", + current_boundary: "src/v1/stage0/src/cli_run/entry_resolve.rs (floor_retention_census, wiring_identity, process_resolve_store_graphs, the byte attribution and its arming); src/v1/stage0/src/cli_run/required_floor_runner.rs (floor_retained_census, its seam calls, the parse-index handover and the fold's last-scope drop); src/v1/stage0/src/cli_run.rs (prepared_graph_without_typecheck_caches); src/v1/stage0/src/cli_run/floor_memory_supervisor.rs (stderr tee and beat transcription); src/v1/stage0/src/target_invocation_host.rs (floor_phase_attribution_rendered); src/v1/stage0/src/v1_interpreter.rs (CROSS_CLAIM_LAST_STORE_DIGEST, take_cross_claim_store_digest); src/v1/stage0/src/cli_run/required_floor_runner.rs (WarmFrameSlot, order_warm_modules, floor_warm_row_identity); dag/gunbc/floor_memory_instrumentation_seed_growth.dag" } diff --git a/dag/gunbc/resolver_cost_frontier.dag b/dag/gunbc/resolver_cost_frontier.dag index 5ad61fcf7ce..12338c8cbcb 100644 --- a/dag/gunbc/resolver_cost_frontier.dag +++ b/dag/gunbc/resolver_cost_frontier.dag @@ -28,6 +28,11 @@ data resolver_cost_frontier_rows: List = [ reason: "every gunbc run demands the full heads reading (tokenize plus heads parse) of every pool file: the module path index is its first demander and pool_parse projects the same memoized reading for the pool name set, the closure name census and the tree bare census. Narrowing the index to module declarations alone would move this cost to pool_census_parse, not remove it, while those whole-pool consumers stay eager. Measured by the gunbc run pre-entry rows graph_facts_import_edges and pool_census_parse (gunbc#12864). For whoever takes M1: the index also refuses a malformed item head anywhere in the pool, a second authority for what run_dag_parse_sweep owns in required CI, but the CLI and local routes do not run the sweep, so retiring that refusal is an authority question to settle per route, not a cost one", dissolution: unbound_dissolution(description: "demand identity for 'this name, in this closure' exists as a carrier (docs/plans/demand-engine-program.md M1), so no consumer of a closure run demands a whole-pool heads reading and the module path index can read module declarations alone"), ), + frontier_row_path( + path: "src/v1/stage0/src/cli_run/required_floor_runner.rs", + reason: "the pure-producer warm builds and drops one WHOLE-CLOSURE evaluation frame per producer module (WarmFrameSlot), held one at a time so the freed frames no longer grow the allocator arena, while a warm reads only a small share of the frame index (the frame-demand reading on gunbc#12890). Holding them one at a time moved their drop into the warm and costs more per frame than holding them together: a CPU residual on the floor accepted as a stated trade against the peak saving (gunbc#12890, lane manager selection of 2026-10-01)", + dissolution: unbound_dissolution(description: "the demand-sized frame consumes the (name, namespace) declarer pool of PR-2 (docs/plans/type-env-single-authority-design.md): a runtime scope resolves each demanded name from the pool-level declarer index that surface_pool_admit admits in the runtime-item namespace, so no frame materializes the indexes of its whole closure"), + ), frontier_row_path( path: "src/v1/stage0/src/cli_run/pool_acquire.rs", reason: "HeadsReading.module is retained for the process: every MultiEntryIndex whose pool contains the file projects it, and a run builds its indexes on demand by phase routing, so no release point is known when the first census projects it (gunbc#12768)", diff --git a/src/v1/stage0/src/cli_run/required_floor_runner.rs b/src/v1/stage0/src/cli_run/required_floor_runner.rs index 67651cfaf2f..b61585bd367 100644 --- a/src/v1/stage0/src/cli_run/required_floor_runner.rs +++ b/src/v1/stage0/src/cli_run/required_floor_runner.rs @@ -5588,6 +5588,12 @@ pub fn floor_seam(name: &str) { // seams rather than to whichever phase the next tick happened to land in. floor_cgroup_stat_beat(&format!("seam-{name}"), None); floor_heap_beat(name); + // THREAD CPU AT THE BOUNDARY, so a phase's cost is read as the floor thread's own CPU between + // two seams rather than wall time, which on a shared host is mostly other jobs' load. + eprintln!( + "[floor-seam-cpu] seam={name} thread_cpu_ms={}", + v1_interpreter::thread_cpu_nanos() / 1_000_000 + ); } /// WHICH STRUCTURES HOLD A SEAM'S RESIDENT SET: the graphs the process resolve store keeps for the @@ -6034,6 +6040,172 @@ pub(crate) fn floor_authority_frame( // vector this function never contributed to. Bounded by neither is not the same as billed to // preparation. One observation per warm row, measured on the same clock and RSS reads as every // other shared build, so all five phases go through ONE refusal. +/// AT MOST ONE MODULE'S AUTHORITY FRAME RESIDENT DURING THE PURE-PRODUCER WARM. A frame is a claim +/// scope over its module's closure -- scoped graph plus scope indexes -- and a warm row needs it only +/// while that module's rows are looked up and warmed. Holding every producer module's frame for the +/// whole warm phase kept them all live at once beside the prepared graph; freed together at the end, +/// they left the allocator's arena grown by their SUM, so the floor's resident peak carried several +/// GB of freed-but-unreturned memory (the uncensored #12799 floor-memory-qualification: the +/// prepared-subject-warm seam's arena and free columns). What outlives a frame is what the floor +/// keeps on purpose: producer nodes (Rc into the prepared graph) and portable stored values. +/// `builds` counts frame constructions, so the CPU side of re-framing on a module change is +/// reported beside the memory it saves. +/// ONE ROW PER WARMED PRODUCER at identity grain, (producer, portable-value digest), for a +/// producer-grain differential between two runs. THE DIGEST IS ONLY RUN-COMPARABLE OVER A +/// CANONICAL PORTABLE ENCODING: until gunbc#12895 puts map entries in content order, a +/// map-bearing value digests in its process's hash order, so two runs can print different +/// digests for one value (recurring_failure_mode portable_value_map_order_is_process_random). +/// A value that stored nothing to digest prints `none`. +fn floor_warm_row_identity(qualified: &str) { + let bare = qualified.rsplit('.').next().unwrap_or(qualified); + let digest = + v1_interpreter::take_cross_claim_store_digest(bare).unwrap_or_else(|| "none".to_string()); + eprintln!("[floor-warm-row] producer={qualified} digest={digest}"); +} + +#[derive(Default)] +struct WarmFrameSlot { + module: Option, + frame: Option, + builds: usize, + framed: std::collections::HashSet, + build_ms: u128, + drop_ms: u128, +} + +impl WarmFrameSlot { + fn frame( + &mut self, + prepared: &PreparedRepository, + corpus_modules: &std::collections::HashSet, + outside_subject: &mut std::collections::BTreeSet, + module: &str, + row_kind: &str, + row: &str, + ) -> Result, String> { + if self.module.as_deref() == Some(module) { + return Ok(self.frame.as_ref()); + } + if outside_subject.contains(module) { + return Ok(None); + } + // A MODULE ALREADY FRAMED IS NOT FRAMED AGAIN: the warm's rows are grouped by module, so a + // second build means a row arrived out of its group -- memory traded for repeated work. This + // refusal is the wall; the closing counts only report. + if self.framed.contains(module) { + return Err(format!( + "REQUIRED-FLOOR REFUSAL cause=PureProducerShareFrameRebuilt module={module} \ + {row_kind}={row} -- the warm's rows are not grouped by module, so one-frame-at-a-time \ + would rebuild a frame it already built" + )); + } + // The held frame goes BEFORE the next is built, so two are never resident together. + let dropping = std::time::Instant::now(); + self.frame = None; + self.module = None; + self.drop_ms += dropping.elapsed().as_millis(); + let building = std::time::Instant::now(); + let built = floor_authority_frame(prepared, module); + self.build_ms += building.elapsed().as_millis(); + match built { + Ok(frame) => { + self.builds += 1; + self.framed.insert(module.to_string()); + self.module = Some(module.to_string()); + self.frame = Some(frame); + Ok(self.frame.as_ref()) + } + Err(_) if !corpus_modules.contains(module) => Err(format!( + "REQUIRED-FLOOR REFUSAL cause=PureProducerShareRowModuleAbsentFromCorpus \ + {row_kind}={row} module={module} — the roster row names a module no source \ + root carries; the row is stale: delete it or restore the module" + )), + Err(why) if why.contains("cause=EntryModuleOutsidePreparedSubject") => { + outside_subject.insert(module.to_string()); + Ok(None) + } + Err(why) => Err(format!( + "REQUIRED-FLOOR REFUSAL cause=PureProducerShareRowModuleUnframeable \ + {row_kind}={row} module={module} — the module is in the corpus and was not \ + excluded as outside the prepared subject, yet it does not frame; the row is \ + NOT shown stale by this, do not delete it to get green: {why}" + )), + } + } +} + +/// The one-pass warm's module order: `candidates` already sorted by (row kind, name), reordered so +/// every module comes after the modules whose acquisitions its carried rows read (`depends_on`). +/// Ties keep the candidate order, so the result is deterministic. A dependency on a module outside +/// `candidates` does not block. A cycle has no order and refuses. +fn order_warm_modules( + candidates: &[String], + depends_on: &std::collections::HashMap>, +) -> Result, String> { + let mut placed: std::collections::HashSet<&str> = std::collections::HashSet::new(); + let mut out: Vec = Vec::with_capacity(candidates.len()); + while out.len() < candidates.len() { + let next = candidates.iter().find(|m| { + !placed.contains(m.as_str()) + && depends_on.get(*m).is_none_or(|deps| { + deps.iter() + .all(|d| placed.contains(d.as_str()) || !candidates.contains(d)) + }) + }); + match next { + Some(m) => { + placed.insert(m.as_str()); + out.push(m.clone()); + } + None => { + let stuck: Vec<&String> = candidates + .iter() + .filter(|m| !placed.contains(m.as_str())) + .collect(); + return Err(format!( + "REQUIRED-FLOOR REFUSAL cause=PureProducerShareModuleOrderCycle modules={stuck:?} \ + -- these modules' carried rows read each other's acquisitions, so no one-pass \ + module order binds every carried input before the row that reads it" + )); + } + } + } + Ok(out) +} + +#[cfg(test)] +mod warm_module_order_tests { + use super::order_warm_modules; + use std::collections::{BTreeSet, HashMap}; + + fn deps(rows: &[(&str, &str)]) -> HashMap> { + let mut m: HashMap> = HashMap::new(); + for (from, to) in rows { + m.entry(from.to_string()) + .or_default() + .insert(to.to_string()); + } + m + } + + // Review 73722's case: `a` and `b` both own acquisitions, and `a`'s carried row reads `b`'s. + // Name order alone puts `a` first; the dependency must put `b` first. + #[test] + fn a_carried_row_runs_after_the_module_owning_its_acquisition() { + let order = order_warm_modules(&["a".into(), "b".into(), "c".into()], &deps(&[("a", "b")])) + .expect("acyclic"); + assert_eq!(order, vec!["b", "a", "c"]); + } + + #[test] + fn a_cross_module_cycle_refuses() { + let refused = + order_warm_modules(&["a".into(), "b".into()], &deps(&[("a", "b"), ("b", "a")])) + .expect_err("cycle"); + assert!(refused.contains("PureProducerShareModuleOrderCycle")); + } +} + pub(crate) fn install_pure_producer_share( prepared: &PreparedRepository, corpus_modules: &std::collections::HashSet, @@ -6090,45 +6262,30 @@ pub(crate) fn install_pure_producer_share( // Pulling every rostered module into every subject instead would re-grow the per-PR // closure toward the corpus on every roster append — cost denominated in the corpus, not // the change (DESIGN section 5), for fills no claim in the subject can consume. - let mut resolution_frames: std::collections::HashMap = - std::collections::HashMap::new(); + // ONE FRAME RESIDENT AT A TIME (WarmFrameSlot): a frame is a claim scope over its module's + // closure, needed only while that module's rows are looked up and warmed. + let mut frames = WarmFrameSlot::default(); let mut outside_subject: std::collections::BTreeSet = std::collections::BTreeSet::new(); // Returns Ok(true) when the module framed, Ok(false) when it is outside this subject but // present in the corpus (disposition 2), Err on staleness or any other framing failure. - let mut frame_rostered_module = - |module: &str, - row_kind: &str, - row: &str, - frames: &mut std::collections::HashMap| - -> Result { - if frames.contains_key(module) { - return Ok(true); - } - if outside_subject.contains(module) { - return Ok(false); - } - match floor_authority_frame(prepared, module) { - Ok(frame) => { - frames.insert(module.to_string(), frame); - Ok(true) - } - Err(_) if !corpus_modules.contains(module) => Err(format!( - "REQUIRED-FLOOR REFUSAL cause=PureProducerShareRowModuleAbsentFromCorpus \ - {row_kind}={row} module={module} — the roster row names a module no source \ - root carries; the row is stale: delete it or restore the module" - )), - Err(why) if why.contains("cause=EntryModuleOutsidePreparedSubject") => { - outside_subject.insert(module.to_string()); - Ok(false) - } - Err(why) => Err(format!( - "REQUIRED-FLOOR REFUSAL cause=PureProducerShareRowModuleUnframeable \ - {row_kind}={row} module={module} — the module is in the corpus and was not \ - excluded as outside the prepared subject, yet it does not frame; the row is \ - NOT shown stale by this, do not delete it to get green: {why}" - )), - } - }; + // ADMISSION NEEDS NODE IDENTITY, NOT A FRAME. A producer's node is the declaration in its module's + // typed items -- the same Rc the frame's scope indexes are built from, which the warm checks by + // pointer -- so admission reads the prepared graph instead of framing every producer module up + // front. A module the prepared graph lacks is outside the subject when the corpus carries it and + // a stale row when it does not. + let prepared_modules: std::collections::HashMap< + &str, + &crate::v1_compiler_infer_items::TypedModule, + > = prepared + .graph + .modules + .iter() + .map(|m| (m.type_env.module_path.as_str(), m.as_ref())) + .collect(); + let mut admitted_by_qualified: std::collections::HashMap< + String, + std::rc::Rc, + > = std::collections::HashMap::new(); let mut admitted_nodes = Vec::new(); let mut admitted_qualified: Vec = Vec::new(); let carried_producers: Vec = carried_rows.iter().map(|r| r.producer.clone()).collect(); @@ -6137,22 +6294,34 @@ pub(crate) fn install_pure_producer_share( .chain(claim_forced_rows.iter()) .chain(carried_producers.iter()) { - let module = match qualified.rsplit_once('.') { - Some((module, _)) => module.to_string(), - None => qualified.clone(), + let (module, decl) = match qualified.rsplit_once('.') { + Some((module, decl)) => (module.to_string(), decl), + None => (qualified.clone(), qualified.as_str()), }; - if !frame_rostered_module(&module, "producer", qualified, &mut resolution_frames)? { - continue; - } - let node = resolution_frames[&module] - .lookup_fn_node(qualified) + let Some(typed) = prepared_modules.get(module.as_str()) else { + if corpus_modules.contains(&module) { + outside_subject.insert(module); + continue; + } + return Err(format!( + "REQUIRED-FLOOR REFUSAL cause=PureProducerShareRowModuleAbsentFromCorpus \ + producer={qualified} module={module} — the roster row names a module no source \ + root carries; the row is stale: delete it or restore the module" + )); + }; + let node = typed + .items + .iter() + .find(|n| n.name == decl) + .cloned() .ok_or_else(|| { format!( "REQUIRED-FLOOR REFUSAL cause=PureProducerShareProducerUnresolved \ producer={qualified} — the rostered spelling names no declaration in its \ - module's frame; fix or delete the roster row" + module; fix or delete the roster row" ) })?; + admitted_by_qualified.insert(qualified.clone(), node.clone()); admitted_nodes.push(node); admitted_qualified.push(qualified.clone()); } @@ -6208,37 +6377,99 @@ pub(crate) fn install_pure_producer_share( > = std::collections::HashMap::new(); let mut inputs_outside_subject: std::collections::HashSet = std::collections::HashSet::new(); - for input in &prepared_inputs { - let module = match input.acquisition.rsplit_once('.') { - Some((module, _)) => module.to_string(), - None => input.acquisition.clone(), - }; - if !frame_rostered_module( - &module, - "acquisition", - &input.acquisition, - &mut resolution_frames, - )? { - inputs_outside_subject.insert(input.acquisition.clone()); - continue; + let module_of = |q: &str| { + q.rsplit_once('.') + .map(|(m, _)| m.to_string()) + .unwrap_or_else(|| q.to_string()) + }; + // ONE PASS, GROUPED BY MODULE. Every row a module owns -- its acquisitions, then its + // carried-input producers, then its plain warms -- runs while that module's frame is the one + // held, so each module is framed exactly once for the whole warm. Modules are ordered by the + // DEPENDENCY the rows declare: a module whose carried row reads an acquisition another module + // owns runs after that module (a topological order, ties broken by (row kind, name) so the order + // is deterministic). Within a module its own acquisitions precede its carried rows, so every + // carried row's input is bound before it runs. A dependency cycle across modules has no such + // order and refuses (`PureProducerShareModuleOrderCycle`). + let rank_of = |module: &str| -> u8 { + if prepared_inputs + .iter() + .any(|i| module_of(&i.acquisition) == module) + { + 0 + } else if carried_rows + .iter() + .any(|r| module_of(&r.producer) == module) + { + 1 + } else { + 2 + } + }; + let mut ordered_modules: Vec = prepared_inputs + .iter() + .map(|i| module_of(&i.acquisition)) + .chain(carried_rows.iter().map(|r| module_of(&r.producer))) + .chain(warm_rows.iter().map(|q| module_of(q))) + .collect::>() + .into_iter() + .collect(); + ordered_modules.sort_by_key(|m| (rank_of(m), m.clone())); + let acquisition_module: std::collections::HashMap<&str, String> = prepared_inputs + .iter() + .map(|i| (i.acquisition.as_str(), module_of(&i.acquisition))) + .collect(); + // module -> the other modules whose acquisitions its carried rows read. + let mut depends_on: std::collections::HashMap> = + std::collections::HashMap::new(); + for row in &carried_rows { + let producer_module = module_of(&row.producer); + if let Some(input_module) = acquisition_module.get(row.carried_input.as_str()) { + if *input_module != producer_module { + depends_on + .entry(producer_module) + .or_default() + .insert(input_module.clone()); + } } - let frame = &resolution_frames[&module]; - let node = frame.lookup_fn_node(&input.acquisition).ok_or_else(|| { + } + let ordered_modules = order_warm_modules(&ordered_modules, &depends_on)?; + for group in &ordered_modules { + for input in prepared_inputs + .iter() + .filter(|i| &module_of(&i.acquisition) == group) + { + let module = match input.acquisition.rsplit_once('.') { + Some((module, _)) => module.to_string(), + None => input.acquisition.clone(), + }; + let Some(frame) = frames.frame( + prepared, + corpus_modules, + &mut outside_subject, + &module, + "acquisition", + &input.acquisition, + )? + else { + inputs_outside_subject.insert(input.acquisition.clone()); + continue; + }; + let node = frame.lookup_fn_node(&input.acquisition).ok_or_else(|| { format!( "REQUIRED-FLOOR REFUSAL cause=PreparedEffectInputUnresolved acquisition={} — the rostered spelling names no declaration in its module's frame; fix or delete the roster row", input.acquisition ) })?; - let (acquired, observation) = observe_shared_build(false, "floor-preparation", || { - v1_interpreter::acquire_prepared_effect_input(frame, &input.acquisition) - }); - let carry = acquired.map_err(|why| { + let (acquired, observation) = observe_shared_build(false, "floor-preparation", || { + v1_interpreter::acquire_prepared_effect_input(frame, &input.acquisition) + }); + let carry = acquired.map_err(|why| { format!( "REQUIRED-FLOOR REFUSAL cause=PreparedEffectInputAcquisitionFailed acquisition={} checkout_input={} — {why}", input.acquisition, input.checkout_input ) })?; - eprintln!( + eprintln!( "[floor-phase] phase=prepared-effect-input-acquire state=completed acquisition={} checkout_input={} content_digest={} disposition={} cpu_ms={} wall_ms={} rss_growth_bytes={}", input.acquisition, input.checkout_input, @@ -6248,93 +6479,119 @@ pub(crate) fn install_pure_producer_share( observation.wall_ms, observation.rss_growth_bytes, ); - v1_interpreter::install_prepared_effect_input(&node, carry); - acquisition_nodes.insert(input.acquisition.clone(), node); - warm_observations.push(( - format!("PreparedEffectInputAcquire/{}", input.acquisition), - observation, - )); - } - for row in &carried_rows { - let producer_module = match row.producer.rsplit_once('.') { - Some((module, _)) => module, - None => row.producer.as_str(), - }; - if outside_subject.contains(producer_module) { - continue; + v1_interpreter::install_prepared_effect_input(&node, carry); + acquisition_nodes.insert(input.acquisition.clone(), node); + warm_observations.push(( + format!("PreparedEffectInputAcquire/{}", input.acquisition), + observation, + )); } - // The producer is demandable in this subject but its input was not prepared here: the - // claim would be served an uncarried value. Refuse, and name the real cause rather - // than reporting the input as unknown to the roster. - if inputs_outside_subject.contains(&row.carried_input) { - return Err(format!( + for row in carried_rows + .iter() + .filter(|r| &module_of(&r.producer) == group) + { + let producer_module = match row.producer.rsplit_once('.') { + Some((module, _)) => module, + None => row.producer.as_str(), + }; + if outside_subject.contains(producer_module) { + continue; + } + // The producer is demandable in this subject but its input was not prepared here: the + // claim would be served an uncarried value. Refuse, and name the real cause rather + // than reporting the input as unknown to the roster. + if inputs_outside_subject.contains(&row.carried_input) { + return Err(format!( "REQUIRED-FLOOR REFUSAL cause=CarriedInputWarmRowInputOutsideSubject producer={} input={} — the producer's module is in the prepared subject but its carried input's module is not; the closure must carry the acquisition's module (an import from the producer's module is the edge preparation follows)", row.producer, row.carried_input )); - } - let acquisition_node = acquisition_nodes.get(&row.carried_input).ok_or_else(|| { + } + if !acquisition_nodes.contains_key(&row.carried_input) + && prepared_inputs + .iter() + .any(|i| i.acquisition == row.carried_input) + { + return Err(format!( + "REQUIRED-FLOOR REFUSAL cause=CarriedInputWarmRowInputNotYetBound producer={} input={} \ + -- the one-pass warm reached this carried row before its acquisition's module, so the \ + module order does not put every acquisition first", + row.producer, row.carried_input + )); + } + let acquisition_node = acquisition_nodes.get(&row.carried_input).ok_or_else(|| { format!( "REQUIRED-FLOOR REFUSAL cause=CarriedInputWarmRowInputUnknown producer={} input={} — the row declares a dependence on an input no floor_cross_claim_prepared_effect_inputs row prepares, so the value it names would never be bound", row.producer, row.carried_input ) })?; - let module = match row.producer.rsplit_once('.') { - Some((module, _)) => module.to_string(), - None => row.producer.clone(), - }; - // Resolution above already framed every rostered producer's module, carried-input rows - // included (they are part of the admitted population), so the frame is present. - let frame = &resolution_frames[&module]; - let producer_node = frame.lookup_fn_node(&row.producer).ok_or_else(|| { + let module = match row.producer.rsplit_once('.') { + Some((module, _)) => module.to_string(), + None => row.producer.clone(), + }; + // Resolution above already framed every rostered producer's module, carried-input rows + // included (they are part of the admitted population), so the frame is present. + let Some(frame) = frames.frame( + prepared, + corpus_modules, + &mut outside_subject, + &module, + "producer", + &row.producer, + )? + else { + continue; + }; + let producer_node = frame.lookup_fn_node(&row.producer).ok_or_else(|| { format!( "REQUIRED-FLOOR REFUSAL cause=PureProducerShareProducerUnresolved producer={} — the rostered spelling names no declaration in its module's frame", row.producer ) })?; - // The implicit binding is installed ONLY for the shape that needs it: a `BoundParameter` - // row's caller passes the carried value itself, so its key already represents the - // content and folding it in a second time would key one call two ways. - if row.bound_parameter.is_none() { - v1_interpreter::install_carried_input_producer(&producer_node, acquisition_node) - .map_err(|why| { - format!( - "REQUIRED-FLOOR REFUSAL cause=CarriedInputWarmRowInputUnknown \ + // The implicit binding is installed ONLY for the shape that needs it: a `BoundParameter` + // row's caller passes the carried value itself, so its key already represents the + // content and folding it in a second time would key one call two ways. + if row.bound_parameter.is_none() { + v1_interpreter::install_carried_input_producer(&producer_node, acquisition_node) + .map_err(|why| { + format!( + "REQUIRED-FLOOR REFUSAL cause=CarriedInputWarmRowInputUnknown \ producer={} input={} — {why}", - row.producer, row.carried_input + row.producer, row.carried_input + ) + })?; + } + let (warm_result, warm_observation) = + observe_shared_build(false, "floor-preparation", || { + v1_interpreter::warm_cross_claim_carried_input_producer( + frame, + &row.producer, + &row.carried_input, + row.bound_parameter.as_deref(), ) - })?; - } - let (warm_result, warm_observation) = - observe_shared_build(false, "floor-preparation", || { - v1_interpreter::warm_cross_claim_carried_input_producer( - frame, - &row.producer, - &row.carried_input, - row.bound_parameter.as_deref(), - ) - }); - match warm_result { - Ok(outcome) => { - if !outcome.is_servable() { - let detail = match outcome.not_portable_detail() { - Some(refusal) => format!( - "{} path={} kind={}", - outcome.cause(), - if refusal.path_into_value.is_empty() { - "" - } else { - refusal.path_into_value.as_str() - }, - refusal.encountered_kind - ), - None => outcome.cause().to_string(), - }; - return Err(format!( + }); + match warm_result { + Ok(outcome) => { + if !outcome.is_servable() { + let detail = match outcome.not_portable_detail() { + Some(refusal) => format!( + "{} path={} kind={}", + outcome.cause(), + if refusal.path_into_value.is_empty() { + "" + } else { + refusal.path_into_value.as_str() + }, + refusal.encountered_kind + ), + None => outcome.cause().to_string(), + }; + return Err(format!( "REQUIRED-FLOOR REFUSAL cause=PureProducerShareWarmNotStored producer={} — the carried-input producer evaluated but its value was refused by the cross-claim store: {detail}", row.producer )); - } - eprintln!( + } + floor_warm_row_identity(&row.producer); + eprintln!( "[floor-phase] phase=prepared-effect-input-warm state=completed producer={} input={} disposition={} cpu_ms={} wall_ms={} rss_growth_bytes={}", row.producer, row.carried_input, @@ -6343,126 +6600,145 @@ pub(crate) fn install_pure_producer_share( warm_observation.wall_ms, warm_observation.rss_growth_bytes, ); - warm_observations.push(( - format!("CrossClaimCarriedInputWarm/{}", row.producer), - warm_observation, - )); - } - Err(why) => { - return Err(format!( + warm_observations.push(( + format!("CrossClaimCarriedInputWarm/{}", row.producer), + warm_observation, + )); + } + Err(why) => { + return Err(format!( "REQUIRED-FLOOR REFUSAL cause=PureProducerShareWarmFailed producer={} — {why}", row.producer )); + } } } - } - for qualified in &warm_rows { - let module = match qualified.rsplit_once('.') { - Some((module, _)) => module.to_string(), - None => qualified.clone(), - }; - // Resolution above either framed this row's module or recorded it as outside the - // prepared subject (not evaluated here, counted below); a stale row already refused. - let Some(producer_frame) = resolution_frames.get(&module) else { - continue; - }; - // PROVENANCE IS DERIVED FROM THE TYPED OUTCOME, NOT ASSERTED BEFORE THE CALL, and the - // first revision of this line got that wrong in the direction DESIGN section 4b names. - // It passed `already_built: false` unconditionally, on the reasoning that the outcome - // below is the authority for whether the value was already retained. THAT REASONING - // FAILS BECAUSE THIS LOOP ALSO REPORTS A PROVENANCE: on the `AlreadyPresent` path the - // receipt said `BuiltByPreparation` for an artifact preparation FOUND rather than built. - // Two representations of one fact with one of them lying is worse than either alone, and - // a fabricated provenance in a receipt is the fabricated-plausible-output failure applied - // to this compiler's own self-description (review 59035, codex/gpt-5.6-sol). - // - // The flag cannot carry it: `observe_shared_build` is told before it runs, and the fact - // does not exist until the call returns. So the observation is corrected AFTER the fact, - // from the outcome that owns it. - // - // THE TRIGGER NAME STATES ONLY WHAT IS DECIDABLE. `AlreadyPresent` establishes PRESENCE - // and not who caused it, so the label names the boundary that is knowable rather than - // fabricating a call site -- inside this loop the only writer that can already have - // stored a rostered producer's value is an earlier rostered producer whose traversal - // reached it. That is the same discipline `warm_bare_reference_edge_index` uses when it - // names `a-site-ahead-of-floor-preparation` instead of inventing an author, and it is - // deliberately weaker than a call-site name because a call site is not recorded. - let (warm_result, mut warm_observation) = - observe_shared_build(false, "floor-preparation", || { - v1_interpreter::warm_cross_claim_pure_producer(producer_frame, qualified) - }); - if let Ok(outcome) = &warm_result { - if matches!( - outcome, - v1_interpreter::CrossClaimStoreOutcome::AlreadyPresent - ) { - warm_observation.provenance = SharedBuildProvenance::AlreadyWarmOnEntry { - triggered_by: "an-earlier-rostered-producer-in-this-warm-loop", - }; + for qualified in warm_rows.iter().filter(|q| &module_of(q) == group) { + let module = match qualified.rsplit_once('.') { + Some((module, _)) => module.to_string(), + None => qualified.clone(), + }; + // Resolution above either framed this row's module or recorded it as outside the + // prepared subject (not evaluated here, counted below); a stale row already refused. + let Some(producer_frame) = frames.frame( + prepared, + corpus_modules, + &mut outside_subject, + &module, + "producer", + qualified, + )? + else { + continue; + }; + let framed = producer_frame.lookup_fn_node(qualified); + let admitted = admitted_by_qualified.get(qualified.as_str()); + if !matches!((&framed, admitted), (Some(f), Some(a)) if std::rc::Rc::ptr_eq(f, a)) { + return Err(format!( + "REQUIRED-FLOOR REFUSAL cause=PureProducerShareFrameLookupDiverges producer={qualified} \ + — the module frame resolves the producer to a different declaration than admission \ + read from the prepared graph, so the admitted identity is not the one evaluated" + )); } - } - match warm_result { - Ok(outcome) => { - // A NON-SERVABLE outcome means nothing is retained for later claims, so a - // silent decline would relocate the fill onto the first toucher: stop the - // line, naming the ONE cause rather than a disjunction of three. An - // `AlreadyPresent` outcome is servable and therefore not a refusal — a - // rostered producer reachable from an earlier rostered producer is stored - // by that traversal, and its own warm correctly finds the work done. - if !outcome.is_servable() { - // The located detail comes from the OUTCOME, so a cause can only ever be - // paired with its own evidence. Reading the retained slot here instead - // would decorate a byte-budget or entry-cap refusal with a stale path - // left by an earlier producer's unportable value (review 57554). - let detail = match outcome.not_portable_detail() { - Some(refusal) => format!( - "{} path={} kind={}", - outcome.cause(), - if refusal.path_into_value.is_empty() { - "" - } else { - refusal.path_into_value.as_str() - }, - refusal.encountered_kind - ), - None => outcome.cause().to_string(), + // PROVENANCE IS DERIVED FROM THE TYPED OUTCOME, NOT ASSERTED BEFORE THE CALL, and the + // first revision of this line got that wrong in the direction DESIGN section 4b names. + // It passed `already_built: false` unconditionally, on the reasoning that the outcome + // below is the authority for whether the value was already retained. THAT REASONING + // FAILS BECAUSE THIS LOOP ALSO REPORTS A PROVENANCE: on the `AlreadyPresent` path the + // receipt said `BuiltByPreparation` for an artifact preparation FOUND rather than built. + // Two representations of one fact with one of them lying is worse than either alone, and + // a fabricated provenance in a receipt is the fabricated-plausible-output failure applied + // to this compiler's own self-description (review 59035, codex/gpt-5.6-sol). + // + // The flag cannot carry it: `observe_shared_build` is told before it runs, and the fact + // does not exist until the call returns. So the observation is corrected AFTER the fact, + // from the outcome that owns it. + // + // THE TRIGGER NAME STATES ONLY WHAT IS DECIDABLE. `AlreadyPresent` establishes PRESENCE + // and not who caused it, so the label names the boundary that is knowable rather than + // fabricating a call site -- inside this loop the only writer that can already have + // stored a rostered producer's value is an earlier rostered producer whose traversal + // reached it. That is the same discipline `warm_bare_reference_edge_index` uses when it + // names `a-site-ahead-of-floor-preparation` instead of inventing an author, and it is + // deliberately weaker than a call-site name because a call site is not recorded. + let (warm_result, mut warm_observation) = + observe_shared_build(false, "floor-preparation", || { + v1_interpreter::warm_cross_claim_pure_producer(producer_frame, qualified) + }); + if let Ok(outcome) = &warm_result { + if matches!( + outcome, + v1_interpreter::CrossClaimStoreOutcome::AlreadyPresent + ) { + warm_observation.provenance = SharedBuildProvenance::AlreadyWarmOnEntry { + triggered_by: "an-earlier-rostered-producer-in-this-warm-loop", }; - return Err(format!( - "REQUIRED-FLOOR REFUSAL cause=PureProducerShareWarmNotStored \ + } + } + match warm_result { + Ok(outcome) => { + // A NON-SERVABLE outcome means nothing is retained for later claims, so a + // silent decline would relocate the fill onto the first toucher: stop the + // line, naming the ONE cause rather than a disjunction of three. An + // `AlreadyPresent` outcome is servable and therefore not a refusal — a + // rostered producer reachable from an earlier rostered producer is stored + // by that traversal, and its own warm correctly finds the work done. + if !outcome.is_servable() { + // The located detail comes from the OUTCOME, so a cause can only ever be + // paired with its own evidence. Reading the retained slot here instead + // would decorate a byte-budget or entry-cap refusal with a stale path + // left by an earlier producer's unportable value (review 57554). + let detail = match outcome.not_portable_detail() { + Some(refusal) => format!( + "{} path={} kind={}", + outcome.cause(), + if refusal.path_into_value.is_empty() { + "" + } else { + refusal.path_into_value.as_str() + }, + refusal.encountered_kind + ), + None => outcome.cause().to_string(), + }; + return Err(format!( + "REQUIRED-FLOOR REFUSAL cause=PureProducerShareWarmNotStored \ producer={qualified} — the rostered producer evaluated but its value \ was refused by the cross-claim store: {detail}" - )); - } - eprintln!( - "[floor-phase] phase=pure-producer-share-warm state=completed \ + )); + } + floor_warm_row_identity(qualified); + eprintln!( + "[floor-phase] phase=pure-producer-share-warm state=completed \ producer={qualified} disposition={} cpu_ms={} wall_ms={} \ rss_growth_bytes={} provenance={}", - outcome.cause(), - warm_observation.cpu_ms, - warm_observation.wall_ms, - warm_observation.rss_growth_bytes, - warm_observation.provenance.render(), - ); - warm_observations.push(( - format!("CrossClaimPureProducerWarm/{qualified}"), - warm_observation, - )); - } - Err(v1_interpreter::PureProducerWarmRefusal::DispatchedEffect { effects }) => { - return Err(format!( - "REQUIRED-FLOOR REFUSAL cause=PureProducerShareWarmDispatchedEffect \ + outcome.cause(), + warm_observation.cpu_ms, + warm_observation.wall_ms, + warm_observation.rss_growth_bytes, + warm_observation.provenance.render(), + ); + warm_observations.push(( + format!("CrossClaimPureProducerWarm/{qualified}"), + warm_observation, + )); + } + Err(v1_interpreter::PureProducerWarmRefusal::DispatchedEffect { effects }) => { + return Err(format!( + "REQUIRED-FLOOR REFUSAL cause=PureProducerShareWarmDispatchedEffect \ producer={qualified} effects={effects} — the warm row reached the world, \ so the value depends on an input its empty argument row cannot represent; \ roster the read as a prepared effect input and the fold as a carried-input \ warm row instead" - )); - } - Err(v1_interpreter::PureProducerWarmRefusal::Failed(why)) => { - return Err(format!( - "REQUIRED-FLOOR REFUSAL cause=PureProducerShareWarmFailed \ + )); + } + Err(v1_interpreter::PureProducerWarmRefusal::Failed(why)) => { + return Err(format!( + "REQUIRED-FLOOR REFUSAL cause=PureProducerShareWarmFailed \ producer={qualified} — {why}" - )); + )); + } } } } @@ -6478,6 +6754,28 @@ pub(crate) fn install_pure_producer_share( .collect::>() .join(",") ); + // REPORTED, NOT A CHECK: the rebuild refusal above enforces one frame per module. The two counts + // come from different sources -- builds from the slot, roster modules from the rows -- so a module + // whose rows never reached a frame shows as a shortfall rather than vanishing. + let roster_modules_in_subject = ordered_modules + .iter() + .filter(|m| !outside_subject.contains(m.as_str())) + .count(); + eprintln!( + "[floor-phase] phase=pure-producer-share-frames state=completed frame_builds={} \ + roster_modules_in_subject={} (one frame resident at a time, rows grouped by module) \ + frame_build_ms={} frame_drop_ms={} last_frame_drop_ms={}", + frames.builds, + roster_modules_in_subject, + frames.build_ms, + frames.drop_ms, + { + let dropping = std::time::Instant::now(); + frames.frame = None; + dropping.elapsed().as_millis() + } + ); + drop(frames); Ok(warm_observations) } diff --git a/src/v1/stage0/src/v1_interpreter.rs b/src/v1/stage0/src/v1_interpreter.rs index 2d0493b5e0e..181ed12f3d0 100644 --- a/src/v1/stage0/src/v1_interpreter.rs +++ b/src/v1/stage0/src/v1_interpreter.rs @@ -2421,6 +2421,11 @@ fn store_cross_claim_pure_memo( return CrossClaimStoreOutcome::RefusedValueNotPortable(refusal); } }; + // The evaluated value's content identity, recorded for the caller BEFORE the presence + // check, so an `AlreadyPresent` warm still reports what THIS evaluation produced. + CROSS_CLAIM_LAST_STORE_DIGEST.with(|d| { + *d.borrow_mut() = Some((func_name.to_string(), portable_value_digest(&portable))) + }); let outcome = CROSS_CLAIM_PURE_MEMO.with(|m| { let mut m = m.borrow_mut(); if let Some(bucket) = m.map.get(&memo_key) { @@ -2467,6 +2472,20 @@ fn store_cross_claim_pure_memo( outcome } +thread_local! { + static CROSS_CLAIM_LAST_STORE_DIGEST: RefCell> = const { RefCell::new(None) }; +} + +/// The portable-form digest of the value the most recent cross-claim store evaluated for +/// `func_name`, taken (and cleared) so a later caller can never read an earlier producer's +/// identity. `None` when that store refused before the value was reified. +pub fn take_cross_claim_store_digest(func_name: &str) -> Option { + CROSS_CLAIM_LAST_STORE_DIGEST.with(|d| match d.borrow_mut().take() { + Some((name, digest)) if name == func_name => Some(digest), + _ => None, + }) +} + /// Why a plain nullary warm stored nothing. Typed apart so the floor names the cause: a /// dispatched effect is a roster defect with its own remedy, not an evaluation failure. #[derive(Debug)]