feat(research): 0106 tasks 1-9 — document layer, content-id move, stores + referential tier

The aura-research leaf crate: ProcessDoc/CampaignDoc with strict parsing
(hand-rolled StageBlock deserializer — deny_unknown_fields does not compose
with internally-tagged enums; Axis declares its ScalarKind once with bare
values, per the #189 user veto), canonical form + content_id_of (the
SHA-256 primitive moved from aura-cli, which now delegates — the id pin set
incl. the independent sweep-store recompute stayed green untouched),
intrinsic validation for both document types (P1 constraints structural),
and the introspection contract (schema tables single-source parse
strictness AND vocabulary/describe/open-slots — the Blockly litmus made
checkable). aura-registry: content-addressed processes/ + campaigns/
stores on the blueprint-store pattern (Ok(None) treat-as-empty) and the
referential tier (validate_campaign_refs: process/strategy refs incl.
identity-scan, axis-name + declared-kind checks against param_space).

Plan deviations, verified by hand and folded back into the plan file:
- Task 9's fixture could not use aura-composites as planned: every shipped
  composite with an open param routes through LinComb, which the zero-arg
  std_vocabulary roster deliberately excludes, so none round-trips through
  blueprint_from_json with the by-type-name resolver. The test hand-builds
  a minimal Bias composite instead (generic over param_space, as planned);
  the unused pro-forma aura-composites dev-dep from the repair pass is
  dropped. This is why the loop reported task 9 BLOCKED
  (review-loop-exhausted on the literal code block); the tree itself is
  complete and green.
- A plan-verbatim test assertion false-matched ("deflate" as substring of
  "deflated-positive"); tightened to the JSON key.
- OpenSlot doc comment backticked (rustdoc read strategies[0] as a link;
  doc gate back to 0 warnings).

Verification: cargo test --workspace 898/0; clippy -D warnings clean;
cargo doc --no-deps 0 warnings. Tasks 10-12 (CLI verb families, final
gates) follow.

refs #189
This commit is contained in:
2026-07-03 15:09:39 +02:00
parent 3149720f21
commit ef3bec5844
9 changed files with 1574 additions and 16 deletions
Generated
+13
View File
@@ -114,6 +114,7 @@ dependencies = [
"aura-engine",
"aura-ingest",
"aura-registry",
"aura-research",
"aura-std",
"clap",
"data-server",
@@ -174,10 +175,22 @@ version = "0.1.0"
dependencies = [
"aura-core",
"aura-engine",
"aura-research",
"aura-std",
"serde",
"serde_json",
]
[[package]]
name = "aura-research"
version = "0.1.0"
dependencies = [
"aura-core",
"serde",
"serde_json",
"sha2",
]
[[package]]
name = "aura-std"
version = "0.1.0"
+1
View File
@@ -17,6 +17,7 @@ members = [
"crates/aura-cli",
"crates/aura-ingest",
"crates/aura-registry",
"crates/aura-research",
]
[workspace.package]
+1
View File
@@ -16,6 +16,7 @@ aura-engine = { path = "../aura-engine" }
# r-sma harness wires (kept out of aura-engine so the engine stays domain-free).
aura-composites = { path = "../aura-composites" }
aura-registry = { path = "../aura-registry" }
aura-research = { path = "../aura-research" }
aura-std = { path = "../aura-std" }
aura-ingest = { path = "../aura-ingest" }
# data-server: the local M1 archive `aura run --real` streams from. Mirrors the
+1 -3
View File
@@ -2585,9 +2585,7 @@ fn sma_signal(fast_len: Option<i64>, slow_len: Option<i64>) -> Composite {
/// two surfaces agree by construction over the same canonical bytes. Research-side
/// (aura-cli), off the frozen engine (invariant 8).
fn content_id(canonical_json: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(canonical_json.as_bytes());
digest.iter().map(|b| format!("{b:02x}")).collect()
aura_research::content_id_of(canonical_json)
}
/// SHA256 (hex) of the canonical (#164, no-trailing-newline) serialization of a
+8 -2
View File
@@ -10,11 +10,17 @@ publish.workspace = true
# back (admitted under the amended C16 per-case policy, INDEX.md). RunReport
# derives serde in aura-engine.
aura-engine = { path = "../aura-engine" }
# the document stores' content-id primitive (put_process/put_campaign key on
# aura_research::content_id_of, the same hash the doc types canonicalize to);
# also PrimitiveBuilder, the referential tier's resolver signature.
aura-core = { path = "../aura-core" }
aura-research = { path = "../aura-research" }
# the lineage record types (FamilyKind / FamilyRunRecord) derive serde; admitted
# under the same per-case policy as serde_json (INDEX.md).
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
[dev-dependencies]
# fixtures construct RunReport/RunManifest literals, whose window needs Timestamp
aura-core = { path = "../aura-core" }
# the referential-tier test's generic param-space fixture (a real vocabulary
# resolver + a zero-arg node, not hand-rolled).
aura-std = { path = "../aura-std" }
+290 -2
View File
@@ -17,10 +17,13 @@ use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use aura_core::PrimitiveBuilder;
use aura_engine::{
expected_max_of_normals, r_metrics_from_rs, resample_block, FamilySelection, MetricStats,
RunReport, SelectionMode, SplitMix64, SweepFamily, SweepPoint,
blueprint_from_json, blueprint_identity_json, expected_max_of_normals, r_metrics_from_rs,
resample_block, FamilySelection, MetricStats, RunReport, SelectionMode, SplitMix64,
SweepFamily, SweepPoint,
};
use aura_research::{CampaignDoc, DocRef};
mod compat;
@@ -131,6 +134,161 @@ impl Registry {
Err(e) => Err(RegistryError::Io(e)),
}
}
/// The content-addressed document-store dir for one document kind —
/// a sibling of the runs store, like `blueprints/`.
fn doc_dir(&self, kind: &str) -> PathBuf {
self.path.with_file_name(kind)
}
/// The single id→path mapping every document put/get routes through
/// (the `blueprint_path` write-one/read-one lockstep discipline).
fn doc_path(&self, kind: &str, content_id: &str) -> PathBuf {
self.doc_dir(kind).join(format!("{content_id}.json"))
}
/// Content-addressed put: computes the content id from the canonical
/// bytes (unlike `put_blueprint`, whose caller owns the hash — document
/// callers always hold canonical bytes, so self-keying is safe here)
/// and writes `<kind>/<id>.json`. Idempotent.
fn put_doc(&self, kind: &str, canonical_json: &str) -> Result<String, RegistryError> {
let id = aura_research::content_id_of(canonical_json);
fs::create_dir_all(self.doc_dir(kind))?;
fs::write(self.doc_path(kind, &id), canonical_json)?;
Ok(id)
}
/// Read a stored document by content id; `Ok(None)` if absent (the
/// `get_blueprint` treat-as-empty discipline).
fn get_doc(&self, kind: &str, content_id: &str) -> Result<Option<String>, RegistryError> {
match fs::read_to_string(self.doc_path(kind, content_id)) {
Ok(s) => Ok(Some(s)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(RegistryError::Io(e)),
}
}
/// Store a canonical process document; returns its content id.
pub fn put_process(&self, canonical_json: &str) -> Result<String, RegistryError> {
self.put_doc("processes", canonical_json)
}
/// Load a stored process document by content id (`Ok(None)` if absent).
pub fn get_process(&self, content_id: &str) -> Result<Option<String>, RegistryError> {
self.get_doc("processes", content_id)
}
/// Store a canonical campaign document; returns its content id.
pub fn put_campaign(&self, canonical_json: &str) -> Result<String, RegistryError> {
self.put_doc("campaigns", canonical_json)
}
/// Load a stored campaign document by content id (`Ok(None)` if absent).
pub fn get_campaign(&self, content_id: &str) -> Result<Option<String>, RegistryError> {
self.get_doc("campaigns", content_id)
}
}
/// Referential-validation findings for a campaign document. By-identifier
/// and Display-free (the CLI phrases them).
#[derive(Clone, Debug, PartialEq)]
pub enum RefFault {
ProcessNotFound(String),
StrategyNotFound(String),
IdentityUnmatched(String),
StrategyUnloadable { id: String, error: String },
AxisNotInParamSpace { strategy: String, axis: String },
AxisKindMismatch { strategy: String, axis: String },
}
impl Registry {
/// Referential tier: resolve the campaign's references against the
/// project's stores and check each axis (name AND declared kind) against
/// the referenced strategy's param space. IO faults are Errors; semantic
/// findings are RefFaults.
pub fn validate_campaign_refs(
&self,
doc: &CampaignDoc,
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<Vec<RefFault>, RegistryError> {
let mut faults = Vec::new();
if let DocRef::ContentId(id) = &doc.process.r#ref
&& self.get_process(id)?.is_none()
{
faults.push(RefFault::ProcessNotFound(id.clone()));
}
for entry in &doc.strategies {
let (label, blueprint_json) = match &entry.r#ref {
DocRef::ContentId(id) => match self.get_blueprint(id)? {
Some(json) => (id.clone(), Some(json)),
None => {
faults.push(RefFault::StrategyNotFound(id.clone()));
(id.clone(), None)
}
},
DocRef::IdentityId(id) => match self.find_blueprint_by_identity(id, resolve)? {
Some(json) => (id.clone(), Some(json)),
None => {
faults.push(RefFault::IdentityUnmatched(id.clone()));
(id.clone(), None)
}
},
};
let Some(json) = blueprint_json else { continue };
let composite = match blueprint_from_json(&json, resolve) {
Ok(c) => c,
Err(e) => {
faults.push(RefFault::StrategyUnloadable {
id: label.clone(),
error: format!("{e:?}"),
});
continue;
}
};
let space = composite.param_space();
for (axis, ax) in &entry.axes {
match space.iter().find(|p| &p.name == axis) {
None => faults.push(RefFault::AxisNotInParamSpace {
strategy: label.clone(),
axis: axis.clone(),
}),
Some(spec) => {
if ax.kind != spec.kind {
faults.push(RefFault::AxisKindMismatch {
strategy: label.clone(),
axis: axis.clone(),
});
}
}
}
}
}
Ok(faults)
}
/// Scan the blueprint store for a blueprint whose identity id matches.
fn find_blueprint_by_identity(
&self,
identity_id: &str,
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<Option<String>, RegistryError> {
let entries = match fs::read_dir(self.blueprints_dir()) {
Ok(e) => e,
Err(_) => return Ok(None), // no store yet -> nothing matches
};
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));
}
}
Ok(None)
}
}
/// Which metric a best-first comparison keys on. Resolves a metric *name* (a
@@ -711,6 +869,21 @@ mod tests {
assert_eq!(reg.get_blueprint("never-written").expect("get"), None);
}
#[test]
fn document_stores_round_trip_by_content_id() {
let reg = Registry::open(temp_family_dir("document_store_round_trip"));
let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
let campaign = r#"{"format_version":1,"kind":"campaign","name":"c"}"#;
let pid = reg.put_process(process).expect("put process");
assert_eq!(pid, aura_research::content_id_of(process));
assert_eq!(reg.get_process(&pid).expect("get process"), Some(process.to_string()));
let cid = reg.put_campaign(campaign).expect("put campaign");
assert_eq!(reg.get_campaign(&cid).expect("get campaign"), Some(campaign.to_string()));
assert_ne!(pid, cid);
// absent id is Ok(None), not an error (treat-as-empty discipline)
assert_eq!(reg.get_process("never-written").expect("get"), None);
}
#[test]
fn corrupt_line_is_a_parse_error_with_line_number() {
let path = temp_path("corrupt");
@@ -1291,6 +1464,121 @@ mod tests {
assert_eq!(g1, g2, "a pure fold must be deterministic (C1)");
}
/// Property: `validate_campaign_refs` resolves campaign document
/// references (content-id and identity-id) against the registry's
/// stores and cross-checks each declared tuning axis (name AND kind)
/// against the resolved strategy's real, generic param space — not a
/// hardcoded param name. Faults are reported per finding kind
/// (ProcessNotFound / StrategyNotFound / AxisNotInParamSpace /
/// AxisKindMismatch), and a fully-resolved, kind-correct document
/// yields no faults at all.
#[test]
fn referential_tier_resolves_refs_and_checks_axes() {
use aura_engine::{blueprint_to_json, Composite};
use aura_research::{Axis, DocRef};
let reg = Registry::open(temp_family_dir("referential_tier"));
let resolve = |t: &str| aura_std::std_vocabulary(t);
// A minimal fixture composite with one OPEN param, built from a
// zero-arg `aura-std` node (`Bias`) so `std_vocabulary` can actually
// resolve it on load. `aura-composites`' shipped composites (vol_stop,
// risk_executor*) all route through `LinComb`, whose builder needs a
// structural arity argument and is therefore deliberately absent from
// the zero-arg `std_vocabulary` roster (see aura-std/src/vocabulary.rs)
// — they cannot round-trip through `blueprint_from_json` with this
// resolver, so this fixture stands in as the "real, open-param,
// vocabulary-loadable composite" the test needs.
let composite = Composite::new(
"fixture",
vec![aura_std::Bias::builder().named("b").into()],
vec![],
vec![],
vec![],
);
let space = composite.param_space();
let real = space.first().expect("fixture composite has an open param");
let real_name = real.name.clone();
let real_kind = real.kind;
let blueprint_json = blueprint_to_json(&composite).expect("serializes");
let bp_id = aura_research::content_id_of(&blueprint_json);
reg.put_blueprint(&bp_id, &blueprint_json).expect("seed blueprint");
let process = r#"{"format_version":1,"kind":"process","name":"p","pipeline":[{"block":"std::generalize","metric":"sqn"}]}"#;
let proc_id = reg.put_process(process).expect("seed process");
// one kind-correct bare value for the real param
let bare = match real_kind {
aura_core::ScalarKind::I64 => "4",
aura_core::ScalarKind::F64 => "1.5",
aura_core::ScalarKind::Bool => "true",
aura_core::ScalarKind::Timestamp => "0",
};
let kind_tag = format!("{real_kind:?}"); // unit-variant Debug == serde string
let campaign_text = format!(
concat!(
r#"{{"format_version":1,"kind":"campaign","name":"c","#,
r#""data":{{"instruments":["GER40"],"windows":[{{"from_ms":1,"to_ms":2}}]}},"#,
r#""strategies":[{{"ref":{{"content_id":"{bp}"}},"#,
r#""axes":{{"{axis}":{{"kind":"{kind}","values":[{val}]}}}}}}],"#,
r#""process":{{"ref":{{"content_id":"{proc}"}}}},"#,
r#""seed":1,"presentation":{{"persist_taps":[],"emit":["family_table"]}}}}"#
),
bp = bp_id,
axis = real_name,
kind = kind_tag,
val = bare,
proc = proc_id,
);
let doc = aura_research::parse_campaign(&campaign_text).expect("campaign parses");
assert_eq!(reg.validate_campaign_refs(&doc, &resolve).expect("io ok"), Vec::new());
// unknown process + unknown strategy ids
let mut missing = doc.clone();
missing.process.r#ref = DocRef::ContentId("00".into());
missing.strategies[0].r#ref = DocRef::ContentId("11".into());
let faults = reg.validate_campaign_refs(&missing, &resolve).expect("io ok");
assert!(faults.contains(&RefFault::ProcessNotFound("00".into())));
assert!(faults.contains(&RefFault::StrategyNotFound("11".into())));
// unknown axis name
let mut bad_axis = doc.clone();
let ax = bad_axis.strategies[0].axes.remove(&real_name).unwrap();
bad_axis.strategies[0].axes.insert("nope".into(), ax);
assert!(reg
.validate_campaign_refs(&bad_axis, &resolve)
.expect("io ok")
.iter()
.any(|f| matches!(f, RefFault::AxisNotInParamSpace { axis, .. } if axis == "nope")));
// declared kind != the param's kind
let wrong_kind = if matches!(real_kind, aura_core::ScalarKind::I64) {
aura_core::ScalarKind::F64
} else {
aura_core::ScalarKind::I64
};
let mut mismatched = doc.clone();
// values were parsed under the right kind; re-kind the axis only —
// the check compares DECLARED kind vs the param's kind
let kept_values = mismatched.strategies[0].axes[&real_name].values.clone();
mismatched.strategies[0].axes.insert(
real_name.clone(),
Axis { kind: wrong_kind, values: kept_values },
);
assert!(reg
.validate_campaign_refs(&mismatched, &resolve)
.expect("io ok")
.iter()
.any(|f| matches!(f, RefFault::AxisKindMismatch { axis, .. } if axis == &real_name)));
// identity ref resolves via the store scan
let identity_json = blueprint_identity_json(&composite).expect("identity form");
let identity_id = aura_research::content_id_of(&identity_json);
let mut by_identity = doc.clone();
by_identity.strategies[0].r#ref = DocRef::IdentityId(identity_id);
assert_eq!(reg.validate_campaign_refs(&by_identity, &resolve).expect("io ok"), Vec::new());
}
#[test]
fn check_r_metric_accepts_r_and_refuses_pip() {
assert!(check_r_metric("expectancy_r").is_ok());
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "aura-research"
edition.workspace = true
version.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
aura-core = { path = "../aura-core" }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
# sha2: content-addressed ids for research documents (mirrors aura-cli's
# topology_hash use, per-case C16 review, SHA256 user-settled).
sha2 = "0.10"
File diff suppressed because it is too large Load Diff
@@ -1724,27 +1724,42 @@ import.)
- [ ] **Step 2: Dev-deps for the fixture, if absent**
In `crates/aura-registry/Cargo.toml` `[dev-dependencies]` add whichever of
these is missing:
In `crates/aura-registry/Cargo.toml` `[dev-dependencies]` add if missing:
```toml
aura-std = { path = "../aura-std" }
aura-composites = { path = "../aura-composites" }
```
(NOT aura-composites: its shipped composites — vol_stop, risk_executor* —
all route through `LinComb`, whose builder needs a structural arity
argument and is therefore deliberately absent from the zero-arg
`std_vocabulary` roster, so none of them can round-trip through
`blueprint_from_json` with the by-type-name resolver this test uses. The
fixture below hand-builds a minimal loadable composite instead.)
- [ ] **Step 3: Append the referential test (tests module). The fixture is
GENERIC over the composite's real param space — no hardcoded param names**
```rust
#[test]
fn referential_tier_resolves_refs_and_checks_axes() {
use aura_engine::{blueprint_to_json, Composite};
use aura_research::{Axis, DocRef};
let reg = Registry::open(temp_family_dir("referential_tier"));
let resolve = |t: &str| aura_std::std_vocabulary(t);
// a real public composite with an open param space
let composite = aura_composites::vol_stop(4, 1.5);
// A minimal fixture composite with one OPEN param, built from a
// zero-arg `aura-std` node (`Bias`) so `std_vocabulary` can actually
// resolve it on load (see Step 2's note on why no aura-composites
// composite qualifies).
let composite = Composite::new(
"fixture",
vec![aura_std::Bias::builder().named("b").into()],
vec![],
vec![],
vec![],
);
let space = composite.param_space();
let real = space.first().expect("fixture composite has an open param");
let real_name = real.name.clone();
@@ -1833,10 +1848,8 @@ Anchor notes: `blueprint_to_json`/`blueprint_identity_json` — import from
the engine re-export the way the crate already imports engine items; if
`aura_core::ScalarKind`/`Scalar` are not yet dev-visible, they come through
the existing `aura-engine` dependency's re-exports or a direct
`aura-core` path dep (mirror whichever the crate already uses). If
`vol_stop(4, 1.5).param_space()` turns out EMPTY, use
`aura_composites::risk_executor_vol_open(1.0)` instead — the test is
generic and needs only ONE open param.
`aura-core` path dep (mirror whichever the crate already uses). The test
is generic and needs only ONE open param from the fixture composite.
- [ ] **Step 4: Run the test**