feat(registry): index-first identity resolution with self-repairing sidecar
Replace the per-call O(store) scan in find_blueprint_by_identity with a persistent identity-id -> content-id sidecar index (blueprint_identity_index.jsonl, sibling of runs.jsonl). Lookups consult the index first and VERIFY every hit by loading that one blueprint under the current roster and recomputing its identity id -- the index is a cache, never an oracle, so results stay scan-identical under roster drift, store surgery, or index corruption (up to the same-identity-twin choice, unspecified in both paths and documented on the fn). Any miss or failed verification runs the old scan as a full-store repair pass: no early return, best-effort append of every absent-or-different mapping, last line wins so later repairs heal stale lines. No write-path, engine, or caller changes; a pre-index store backfills itself on the first miss; a read-only store keeps scanning as before. Decided against put-time index maintenance: it would need a roster-free doc-level identity function whose equivalence to the loaded-composite path no green test ratifies (the composite path canonicalizes structurally on load); the repair path is the load-bearing mechanism either way. Decision log: the issue's comments. Verification: 7 new aura-registry tests (backfill, verified-hit-no-walk via a poison-dir probe, one-pass legacy backfill, stale-line healing, unloadable-under-roster preservation, garbage tolerance) plus a CLI e2e (campaign validate resolving an identity ref cold then warm); workspace suite 1346 passed / 0 failed; clippy -D warnings clean; doc build clean. closes #191
This commit is contained in:
@@ -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<String> },
|
||||
}
|
||||
|
||||
/// 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<String, String> {
|
||||
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::<IdentityIndexLine>(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<PrimitiveBuilder>,
|
||||
) -> Result<Option<String>, 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::<PrimitiveBuilder>;
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user