diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index afb143a..87ab115 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -416,6 +416,164 @@ fn campaign_validate_in_project_reports_referential_tier_end_to_end() { // drop, at the end of this scope — including on a mid-test panic. } +/// Property (#191, index-first identity lookup at the CLI seam): a strategy +/// referenced by `identity_id` (never previously exercised end to end — every +/// prior campaign e2e fixture used `content_id`) resolves through `campaign +/// validate`'s referential tier exactly like a `content_id` ref. The FIRST +/// resolution has no sidecar index yet, so `Registry::find_blueprint_by_identity` +/// falls back to the store scan and backfills `blueprint_identity_index.jsonl` +/// as a repair pass; the SECOND resolution goes through that now-present index +/// (a verified hit). Both calls must answer identically — this is the E2E +/// manifestation of "index-first lookup with verify-on-hit and repair-walk": +/// if the backfilled index or its verify-on-hit read disagreed with the scan, +/// only the second call would regress. +#[test] +fn campaign_validate_resolves_identity_ref_via_index_first_lookup_then_index_hit() { + let (dir, _fixture) = fresh_project(); + let runs_dir = dir.join("runs"); + std::fs::remove_dir_all(&runs_dir).ok(); + let _cleanup = ScratchGuard(vec![ + ScratchPath::Dir(runs_dir.clone()), + ScratchPath::File(dir.join("seed.process.json")), + ScratchPath::File(dir.join("identity-ok.campaign.json")), + ]); + + // Seed one real, content-addressed blueprint (mirrors the content_id + // sibling test above). + let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + let (sweep_out, sweep_code) = run_code_in( + &dir, + &[ + "sweep", + &closed_bp, + "--axis", + "sma_signal.fast.length=2,4", + "--axis", + "sma_signal.slow.length=8,16", + "--name", + "campaign-identity-seed", + ], + ); + assert_eq!(sweep_code, Some(0), "seed sweep failed: {sweep_out}"); + let bp_id = std::fs::read_dir(runs_dir.join("blueprints")) + .expect("blueprints dir") + .next() + .expect("one stored blueprint") + .expect("dir entry") + .path() + .file_stem() + .expect("stem") + .to_string_lossy() + .into_owned(); + let bp_path = runs_dir.join("blueprints").join(format!("{bp_id}.json")); + + // The identity id of the SAME stored bytes — `--content-id FILE + // --identity-id` combined, over the store's own file so the computed + // identity id is guaranteed to match what the registry recomputes on a + // lookup (no risk of drift from re-deriving it off a differently-bound + // copy of the blueprint). + let (id_out, id_code) = + run_code_in(&dir, &["graph", "introspect", "--content-id", bp_path.to_str().expect("utf8 path"), "--identity-id"]); + assert_eq!(id_code, Some(0), "stdout/stderr: {id_out}"); + let mut id_lines = id_out.lines(); + let printed_content_id = id_lines.next().expect("content id line"); + assert_eq!(printed_content_id, bp_id, "content id of the stored file matches its store key"); + let identity_id = id_lines.next().expect("identity id line").to_string(); + + let exec_process = PROCESS_DOC + .replacen( + "\"select\": \"plateau:worst\", \"deflate\": true", + "\"select\": \"argmax\", \"deflate\": false", + 1, + ) + .replacen("\"overfit_probability\", \"cmp\": \"lt\", \"value\": 0.1", "\"win_rate\", \"cmp\": \"lt\", \"value\": 1.1", 1); + write_doc(&dir, "seed.process.json", &exec_process); + let (proc_out, proc_code) = run_code_in(&dir, &["process", "register", "seed.process.json"]); + assert_eq!(proc_code, Some(0), "seed process register failed: {proc_out}"); + let proc_id = proc_out + .lines() + .find(|l| l.starts_with("registered process ")) + .expect("register line") + .trim_start_matches("registered process ") + .split(' ') + .next() + .expect("id") + .to_string(); + + let campaign = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "identity-ref-check", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1, "to_ms": 2 }} ] }}, + "strategies": [ {{ "ref": {{ "identity_id": "{identity}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [2] }}, + "slow.length": {{ "kind": "I64", "values": [6] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc}" }} }}, + "seed": 1, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + identity = identity_id, + proc = proc_id + ); + write_doc(&dir, "identity-ok.campaign.json", &campaign); + + let sidecar = runs_dir.join("blueprint_identity_index.jsonl"); + assert!(!sidecar.exists(), "no index sidecar before the first identity lookup"); + + // First call: no index -> scan fallback -> repair-walk backfills the sidecar. + let (out1, code1) = run_code_in(&dir, &["campaign", "validate", "identity-ok.campaign.json"]); + assert_eq!(code1, Some(0), "stdout/stderr: {out1}"); + assert!(out1.contains( + "campaign document valid (referential): all references resolve, axes are in the param space" + )); + assert!(sidecar.is_file(), "the scan fallback must backfill the sidecar index"); + + // Second call: the sidecar now exists -> a verified index hit. Same answer. + let (out2, code2) = run_code_in(&dir, &["campaign", "validate", "identity-ok.campaign.json"]); + assert_eq!(code2, Some(0), "stdout/stderr: {out2}"); + assert_eq!(out1, out2, "index-hit resolution must answer identically to the scan fallback"); +} + +/// Property (#191, negative twin): an `identity_id` ref that matches no +/// stored blueprint is reported through the SAME `ref_fault_prose` +/// `IdentityUnmatched` mapping as `content_id`'s `StrategyNotFound` — pinned +/// as a unit-tested Debug-free string (`ref_fault_prose_is_debug_free`) but +/// never before driven through the actual `campaign validate` CLI seam, +/// so a wiring break between the fault type and its CLI dispatch would not +/// have failed any existing test. +#[test] +fn campaign_validate_reports_unresolved_identity_ref_end_to_end() { + // Referential tier only runs inside a project (`campaign_validate_outside_project_skips_referential_tier`); + // a plain `temp_cwd` would silently pass at the intrinsic tier instead. + let (dir, _fixture) = fresh_project(); + let runs_dir = dir.join("runs"); + std::fs::remove_dir_all(&runs_dir).ok(); + let _cleanup = ScratchGuard(vec![ + ScratchPath::Dir(runs_dir), + ScratchPath::File(dir.join("identity-miss.campaign.json")), + ]); + let campaign = r#"{ + "format_version": 1, + "kind": "campaign", + "name": "identity-ref-miss", + "data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] }, + "strategies": [ { "ref": { "identity_id": "0000000000000000000000000000000000000000000000000000000000000000" }, + "axes": { "fast": { "kind": "I64", "values": [8] } } } ], + "process": { "ref": { "content_id": "4e2d" } }, + "seed": 1, + "presentation": { "persist_taps": [], "emit": ["family_table"] } +}"#; + write_doc(&dir, "identity-miss.campaign.json", campaign); + let (out, code) = run_code_in(&dir, &["campaign", "validate", "identity-miss.campaign.json"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!(out.contains("campaign references do not resolve:"), "stdout/stderr: {out}"); + assert!( + out.contains("matches no stored blueprint"), + "the IdentityUnmatched fault prose must appear: {out}" + ); +} + const CAMPAIGN_DOC: &str = r#"{ "format_version": 1, "kind": "campaign", diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index 0b1f983..2e2fc3b 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -49,8 +49,10 @@ pub struct Registry { /// read-the-counter-then-write critical section (`append_family`, /// `append_campaign_run` in `lineage.rs`), so concurrent writers through /// one handle also see a consistent per-name run counter. Store-agnostic: - /// it guards all three JSONL append paths — the flat runs store - /// ([`Registry::append`]), `families.jsonl`, and `campaign_runs.jsonl` + /// it guards all four JSONL append paths — the flat runs store + /// ([`Registry::append`]), `families.jsonl`, `campaign_runs.jsonl`, and the + /// blueprint identity index (`blueprint_identity_index.jsonl`, + /// `append_identity_index`) /// (they never contend on the same file, but share this one lock). /// In-process only; cross-process locking stays out of contract. append_write_lock: std::sync::Mutex<()>, @@ -240,6 +242,17 @@ pub enum RefFault { BindingRoleUnknown { role: String, roles: Vec }, } +/// One identity-index mapping: the identity id (debug-symbol-blind hash) of +/// a stored blueprint → the content id (byte-exact hash) it lives at. One +/// JSON line per mapping in the `blueprint_identity_index.jsonl` sidecar +/// (#191); duplicate identity ids resolve last-line-wins at read time, so a +/// later repair append heals an earlier stale line. +#[derive(serde::Serialize, serde::Deserialize)] +struct IdentityIndexLine { + identity_id: String, + content_id: String, +} + impl Registry { /// Referential tier: resolve the campaign's references against the /// project's stores and check each axis (name AND declared kind) against @@ -350,28 +363,128 @@ impl Registry { Ok(faults) } - /// Scan the blueprint store for a blueprint whose identity id matches — + /// The identity-index sidecar path — the `families.jsonl` discipline: a + /// fixed-name sibling of the runs store, so isolation stays + /// per-directory (#191). + fn identity_index_path(&self) -> PathBuf { + self.path.with_file_name("blueprint_identity_index.jsonl") + } + + /// Append one identity→content mapping as a single JSON line, creating + /// the file (and its parent directory) if absent. Serialized by the + /// internal `append_write_lock` (#276) like every other JSONL append + /// path. Callers treat the result as best-effort: the index is a cache, + /// and a cache write must never degrade a correct read. + fn append_identity_index(&self, identity_id: &str, content_id: &str) -> Result<(), RegistryError> { + let _guard = self.append_write_lock.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let path = self.identity_index_path(); + if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { + fs::create_dir_all(parent)?; + } + let line = serde_json::to_string(&IdentityIndexLine { + identity_id: identity_id.to_string(), + content_id: content_id.to_string(), + }) + .expect("two plain strings serialize"); + let mut file = fs::OpenOptions::new().create(true).append(true).open(&path)?; + writeln!(file, "{line}")?; + Ok(()) + } + + /// Read the index into a LAST-line-wins identity→content map. The index + /// is a pure cache with the scan as its truth path, so reads never fail + /// the lookup: a missing or unreadable file is an empty map, and a line + /// that does not parse (torn write, manual edit) is skipped. + fn load_identity_index(&self) -> std::collections::BTreeMap { + let Ok(text) = fs::read_to_string(self.identity_index_path()) else { + return std::collections::BTreeMap::new(); + }; + let mut map = std::collections::BTreeMap::new(); + for raw in text.lines() { + if let Ok(line) = serde_json::from_str::(raw) { + map.insert(line.identity_id, line.content_id); + } + } + map + } + + /// Resolve an identity id to a stored blueprint's canonical JSON — /// public so a campaign run resolves the same identity refs the /// referential tier validates. + /// + /// Index-first (#191): the persistent `blueprint_identity_index.jsonl` + /// sidecar is consulted and every hit is VERIFIED by loading that one + /// blueprint under the current `resolve` roster and recomputing its + /// identity id — the index is a cache, never an oracle, so a verified hit + /// is always a *valid* match (same identity id) under roster drift, store + /// surgery, or index corruption. Any miss or failed verification falls + /// back to the scan, which doubles as a full-store repair pass: it walks + /// every entry (no early return), recomputes each loadable entry's + /// identity id exactly as before, and best-effort appends every mapping + /// the index lacks or has wrong. A pre-index store therefore backfills + /// itself on the first miss; a read-only store simply keeps scanning as + /// it always has. + /// + /// One corner is *not* byte-identical to the scan: two stored blueprints + /// that differ only in fields excluded from the identity hash (e.g. debug + /// names) share one `identity_id` but have distinct `content_id`s. The + /// scan always answers with the first `read_dir` match; a verified index + /// hit answers with whichever of those content ids the index currently + /// maps the identity to, which need not be the first match. Both are + /// valid blueprints for that identity, so the answer is correct either + /// way, just not guaranteed to pick the same one as an uncached scan. pub fn find_blueprint_by_identity( &self, identity_id: &str, resolve: &dyn Fn(&str) -> Option, ) -> Result, RegistryError> { + let index = self.load_identity_index(); + if let Some(content_id) = index.get(identity_id) + && let Some(json) = self.get_blueprint(content_id)? + && let Ok(composite) = blueprint_from_json(&json, resolve) + && let Ok(identity_json) = blueprint_identity_json(&composite) + && aura_research::content_id_of(&identity_json) == identity_id + { + return Ok(Some(json)); + } + // Miss or failed verification: today's scan, as a repair pass. The + // answer comes from the walk (first `read_dir` match, as before), + // never from the repaired index. let entries = match fs::read_dir(self.blueprints_dir()) { Ok(e) => e, Err(_) => return Ok(None), // no store yet -> nothing matches }; + let mut found = None; for entry in entries { let entry = entry?; let json = fs::read_to_string(entry.path())?; let Ok(composite) = blueprint_from_json(&json, resolve) else { continue }; let Ok(identity_json) = blueprint_identity_json(&composite) else { continue }; - if aura_research::content_id_of(&identity_json) == identity_id { - return Ok(Some(json)); + let entry_identity = aura_research::content_id_of(&identity_json); + // The filename stem is the content id — `blueprint_path`'s + // write-one/read-one mapping, read back. + let Some(content_id) = entry.path().file_stem().and_then(|s| s.to_str().map(String::from)) + else { + continue; + }; + if index.get(&entry_identity) != Some(&content_id) { + // absent-or-different only, so a walk over an already-correct + // index appends nothing — *for the common one-content-per- + // identity case*. In the duplicate-identity/distinct-content + // corner (two stored blueprints sharing an identity id, e.g. + // differing only in debug names), `index` is this call's + // snapshot and is never updated mid-walk, so every entry + // whose content id differs from that stale snapshot appends + // a line, one per full-scan lookup, even though each write is + // individually a valid mapping. Best-effort regardless — a + // cache write must never degrade a correct read. + let _ = self.append_identity_index(&entry_identity, &content_id); + } + if found.is_none() && entry_identity == identity_id { + found = Some(json); } } - Ok(None) + Ok(found) } } @@ -1781,6 +1894,183 @@ mod tests { assert_eq!(reg.validate_campaign_refs(&by_identity, &resolve).expect("io ok"), Vec::new()); } + /// Property (#191): the identity-index sidecar is a last-line-wins cache + /// over identity id → content id — a missing file reads as empty (no + /// error, since the scan is the truth path), a later append for the same + /// identity id heals an earlier stale line, and a garbage line (torn + /// concurrent write, manual edit) is skipped rather than failing the read. + #[test] + fn identity_index_reads_last_line_wins_and_skips_garbage() { + let reg = Registry::open(temp_family_dir("identity_index_read")); + // missing file: empty map, no error — the index is a cache and the + // scan is the truth path. + assert!(reg.load_identity_index().is_empty()); + reg.append_identity_index("id-a", "content-1").expect("append"); + reg.append_identity_index("id-b", "content-2").expect("append"); + // a stale line for id-a is healed by a later append: last line wins. + reg.append_identity_index("id-a", "content-3").expect("append"); + // garbage lines (torn concurrent write, manual edit) are skipped. + let text = fs::read_to_string(reg.identity_index_path()).expect("index exists"); + fs::write(reg.identity_index_path(), format!("{text}not json\n")).expect("garbage line"); + let map = reg.load_identity_index(); + assert_eq!(map.get("id-a").map(String::as_str), Some("content-3")); + assert_eq!(map.get("id-b").map(String::as_str), Some("content-2")); + assert_eq!(map.len(), 2); + } + + /// Store a composite as a canonical blueprint and return its + /// (blueprint_json, content_id, identity_id) — the seeding pattern of + /// `referential_tier_resolves_refs_and_checks_axes`, factored for the + /// identity-index tests. + fn seed_blueprint(reg: &Registry, composite: &aura_engine::Composite) -> (String, String, String) { + let blueprint_json = aura_engine::blueprint_to_json(composite).expect("serializes"); + let content_id = aura_research::content_id_of(&blueprint_json); + reg.put_blueprint(&content_id, &blueprint_json).expect("seed blueprint"); + let identity_json = blueprint_identity_json(composite).expect("identity form"); + let identity_id = aura_research::content_id_of(&identity_json); + (blueprint_json, content_id, identity_id) + } + + /// The referential-tier fixture: a one-node composite over the zero-arg + /// vocabulary node `Bias`, loadable via `std_vocabulary`. + fn bias_fixture() -> aura_engine::Composite { + aura_engine::Composite::new( + "fixture", + vec![aura_std::Bias::builder().named("b").into()], + vec![], + vec![], + vec![], + ) + } + + /// A second, identity-distinct fixture: same shape over the zero-arg + /// vocabulary node `Delay`. Distinct node types are identity-bearing + /// (only debug names are identity-blind), so the two fixtures give the + /// store two entries with different identity ids. + fn delay_fixture() -> aura_engine::Composite { + aura_engine::Composite::new( + "fixture2", + vec![aura_std::Delay::builder().named("d").into()], + vec![], + vec![], + vec![], + ) + } + + #[test] + fn identity_index_lookup_backfills_and_resolves() { + let reg = Registry::open(temp_family_dir("identity_backfill")); + let resolve = |t: &str| aura_std::std_vocabulary(t); + let (blueprint_json, content_id, identity_id) = seed_blueprint(®, &bias_fixture()); + + // no index yet: the lookup answers via the scan fallback... + assert!(!reg.identity_index_path().exists()); + let found = reg.find_blueprint_by_identity(&identity_id, &resolve).expect("io ok"); + assert_eq!(found.as_deref(), Some(blueprint_json.as_str())); + // ...and repairs the index in the same pass. + assert_eq!(reg.load_identity_index().get(&identity_id), Some(&content_id)); + // the second lookup resolves identically. + let again = reg.find_blueprint_by_identity(&identity_id, &resolve).expect("io ok"); + assert_eq!(again.as_deref(), Some(blueprint_json.as_str())); + } + + #[test] + fn identity_index_verified_hit_answers_without_a_store_walk() { + let reg = Registry::open(temp_family_dir("identity_hit_no_walk")); + let resolve = |t: &str| aura_std::std_vocabulary(t); + let (blueprint_json, _content_id, identity_id) = seed_blueprint(®, &bias_fixture()); + + // backfill the index, then poison the store: a DIRECTORY named like + // a blueprint makes any scan's read_to_string error, so only a + // walk-free hit can succeed past it. + reg.find_blueprint_by_identity(&identity_id, &resolve).expect("backfill"); + fs::create_dir_all(reg.blueprints_dir().join("poison.json")).expect("plant poison dir"); + + // verified hit: no walk, the poison is never touched. + let found = reg.find_blueprint_by_identity(&identity_id, &resolve).expect("hit path"); + assert_eq!(found.as_deref(), Some(blueprint_json.as_str())); + // an unknown identity must scan — and the poison proves the scan + // would have bitten: the lookup errors instead of answering. + assert!(reg.find_blueprint_by_identity("0000", &resolve).is_err()); + } + + #[test] + fn identity_index_legacy_store_backfills_in_one_pass() { + let reg = Registry::open(temp_family_dir("identity_legacy_backfill")); + let resolve = |t: &str| aura_std::std_vocabulary(t); + let (_json_a, content_a, identity_a) = seed_blueprint(®, &bias_fixture()); + let (_json_b, content_b, identity_b) = seed_blueprint(®, &delay_fixture()); + assert_ne!(identity_a, identity_b, "fixtures must be identity-distinct"); + assert!(!reg.identity_index_path().exists()); + + // one miss-lookup (an absent identity) walks the store once and + // returns None — only the scan can prove absence... + assert_eq!(reg.find_blueprint_by_identity("0000", &resolve).expect("io ok"), None); + // ...and that single walk backfills a mapping for EVERY loadable + // entry, not just the queried one. + let map = reg.load_identity_index(); + assert_eq!(map.get(&identity_a), Some(&content_a)); + assert_eq!(map.get(&identity_b), Some(&content_b)); + assert_eq!(map.len(), 2); + } + + #[test] + fn identity_index_stale_lines_degrade_to_scan_and_heal() { + let reg = Registry::open(temp_family_dir("identity_stale_heals")); + let resolve = |t: &str| aura_std::std_vocabulary(t); + let (json_a, content_a, identity_a) = seed_blueprint(®, &bias_fixture()); + let (_json_b, content_b, _identity_b) = seed_blueprint(®, &delay_fixture()); + + // stale shape 1: identity → an absent content id. Verify-on-hit + // cannot load it, the scan answers, the repair appends the correct + // pair behind the stale line — last line wins. + reg.append_identity_index(&identity_a, "feedface").expect("stale line"); + let found = reg.find_blueprint_by_identity(&identity_a, &resolve).expect("io ok"); + assert_eq!(found.as_deref(), Some(json_a.as_str())); + assert_eq!(reg.load_identity_index().get(&identity_a), Some(&content_a)); + + // stale shape 2: identity → an EXISTING content id whose recomputed + // identity differs. Verify-on-hit refuses, the scan answers, the + // repair heals again. + reg.append_identity_index(&identity_a, &content_b).expect("stale line"); + let found = reg.find_blueprint_by_identity(&identity_a, &resolve).expect("io ok"); + assert_eq!(found.as_deref(), Some(json_a.as_str())); + assert_eq!(reg.load_identity_index().get(&identity_a), Some(&content_a)); + + // healed for good: the hit path now clears a scan-poisoned store. + fs::create_dir_all(reg.blueprints_dir().join("poison.json")).expect("plant poison dir"); + let again = reg.find_blueprint_by_identity(&identity_a, &resolve).expect("hit path"); + assert_eq!(again.as_deref(), Some(json_a.as_str())); + } + + #[test] + fn identity_index_unloadable_under_roster_stays_unmatched() { + let reg = Registry::open(temp_family_dir("identity_unloadable")); + let resolve = |t: &str| aura_std::std_vocabulary(t); + let (_json, _content_id, identity_id) = seed_blueprint(®, &bias_fixture()); + reg.find_blueprint_by_identity(&identity_id, &resolve).expect("backfill"); + + // under a roster that cannot load the blueprint the verified hit + // fails and the scan skips the entry — `IdentityUnmatched` semantics + // preserved exactly (a preservation pin: green before AND after the + // lookup rework). + let empty = |_t: &str| None::; + assert_eq!(reg.find_blueprint_by_identity(&identity_id, &empty).expect("io ok"), None); + } + + #[test] + fn identity_index_garbage_line_does_not_break_resolution() { + let reg = Registry::open(temp_family_dir("identity_garbage_line")); + let resolve = |t: &str| aura_std::std_vocabulary(t); + let (blueprint_json, _content_id, identity_id) = seed_blueprint(®, &bias_fixture()); + + // a wholly-garbage index file must not break resolution (a + // preservation pin — the scan is the truth path). + fs::write(reg.identity_index_path(), "not json at all\n").expect("garbage index"); + let found = reg.find_blueprint_by_identity(&identity_id, &resolve).expect("io ok"); + assert_eq!(found.as_deref(), Some(blueprint_json.as_str())); + } + /// Property (#246): a campaign axis naming a BOUND param (not an open /// one) is checked exactly like an open-param axis — a kind-correct axis /// over the bound path is fault-free, and a kind-mismatched one over that