feat(project): the project-as-crate load boundary (cycle 0102)

A research project is now a loadable external cdylib crate. Inside a
directory whose ancestry holds an Aura.toml, aura discovers the project
root cargo-style, locates the compiled dylib via cargo metadata (debug
default, --release opt-in), loads it load-and-hold, and refuses
mismatches before trusting anything: the AURA_PROJECT descriptor
(aura-core::project, #[repr(C)]) carries a C-ABI stamp prefix (rustc +
aura-core version, baked per consuming build by the new aura-core
build.rs) validated before any Rust-ABI field is read. The vocabulary
charter gates the merged resolution: project type ids are ::-namespaced
(std stays bare), duplicates refuse, and the enumerable type-id list
must agree with the resolver, so introspection can never silently omit
a project type.

All blueprint verbs resolve through the merged project + std vocabulary
via a per-invocation Env threaded through the dispatch chains;
registry, trace-store, and data paths anchor at the project runs root
(Aura.toml [paths], paths-only by design — instrument geometry stays
the recorded sidecar, C15). RunManifest gains the Tier-1 project
provenance field (namespace + dylib sha256 + best-effort commit),
stamped beside topology_hash on the blueprint-run paths; pre-0102
registry lines load unchanged. Default node names strip the namespace,
so :: never reaches the param-path address space.

Proven by the demo-project fixture (built by the e2e via cargo,
path-dep on this workspace): run twice bit-identical, provenance
recorded, introspection lists demo::* beside std, registry anchors at
the discovered root from a subdirectory; the badcharter fixture proves
the charter refusal through the real libloading path; a never-built
project refuses with a cargo-build hint. Outside a project every path
collapses to the previous literals — goldens and manifest pins
byte-identical.

Verification: cargo build --workspace clean; cargo test --workspace 862
passed / 0 failed (incl. 7 project_load e2e); clippy -D warnings clean
(one precedent-matching allow(too_many_arguments) on run_oos_blueprint,
whose arity the Env threading raised to 8); doc build unchanged.
Docs/ledger aligned: Aura.toml field lists are paths-only in
project-layout.md, glossary, C16/C17; new C13 realization note records
the per-invocation-reload reading and the load-and-hold one-shot scope
boundary.

New deps, per-case review (aura-cli leaf binary only, never the frozen
artifact): libloading, toml.

refs #180
This commit is contained in:
2026-07-02 18:13:37 +02:00
parent af5f825d60
commit 4928e289f7
38 changed files with 1662 additions and 291 deletions
+1
View File
@@ -1003,6 +1003,7 @@ mod tests {
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: summarize(&equity, &exposure),
}
+2 -1
View File
@@ -71,7 +71,8 @@ pub use harness::{
pub use report::{
derive_position_events, expected_max_of_normals, f64_field, inv_norm_cdf, join_on_ts,
r_metrics_from_rs, summarize, summarize_r, ColumnarTrace, FamilySelection, JoinedRow,
PositionAction, PositionEvent, RMetrics, RunManifest, RunMetrics, RunReport, SelectionMode,
PositionAction, PositionEvent, ProjectProvenance, RMetrics, RunManifest, RunMetrics,
RunReport, SelectionMode,
};
pub use sweep::{
sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint,
+2
View File
@@ -245,6 +245,7 @@ mod tests {
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: summarize(&equity, &exposure),
}
@@ -271,6 +272,7 @@ mod tests {
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: RunMetrics {
total_pips: v,
+46
View File
@@ -57,6 +57,23 @@ pub struct RunManifest {
/// serde widening (Tier-1, #156), identical idiom to `selection` / `instrument`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub topology_hash: Option<String>,
/// Identity of the loaded project dylib, when the run used one (cycle
/// 0102). Pre-0102 records read as `None`; `None` serializes as an absent
/// key (the same one-directional widening as `instrument`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<ProjectProvenance>,
}
/// Provenance of the project cdylib a run was resolved through (C16/C18).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ProjectProvenance {
/// The project's vocabulary namespace (from the descriptor).
pub namespace: String,
/// SHA-256 (hex) of the loaded dylib file's bytes.
pub dylib_sha256: String,
/// The project repo's HEAD (+ `-dirty`), when derivable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
}
/// A run's full structured result: the descriptor plus the metrics it
@@ -280,6 +297,7 @@ mod tests {
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
seed: 0, broker: "b".into(), selection: None, instrument: None,
topology_hash: None,
project: None,
};
assert!(!serde_json::to_string(&m).unwrap().contains("selection"));
}
@@ -298,6 +316,7 @@ mod tests {
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
seed: 0, broker: "b".into(), selection: None, instrument: Some("GER40".into()),
topology_hash: None,
project: None,
};
let json = serde_json::to_string(&with).unwrap();
assert!(json.contains("\"instrument\":\"GER40\""), "json: {json}");
@@ -308,11 +327,33 @@ mod tests {
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
seed: 0, broker: "b".into(), selection: None, instrument: None,
topology_hash: None,
project: None,
};
// skip_serializing_if omits the key when None -> existing manifest bytes unchanged (C23).
assert!(!serde_json::to_string(&without).unwrap().contains("instrument"));
}
#[test]
fn runmanifest_project_field_round_trips_and_omits_when_none() {
// A pre-0102 line (no "project" key) must load with project: None.
let legacy = r#"{"commit":"c","params":[],"window":[0,1],"seed":7,"broker":"b"}"#;
let m: RunManifest = serde_json::from_str(legacy).expect("legacy line loads");
assert_eq!(m.project, None);
// None must serialize with the key absent (byte-stability of old paths).
let out = serde_json::to_string(&m).expect("serializes");
assert!(!out.contains("\"project\""));
// Some(..) must round-trip.
let p = ProjectProvenance {
namespace: "demo".into(),
dylib_sha256: "ab".repeat(32),
commit: None,
};
let m2 = RunManifest { project: Some(p.clone()), ..m };
let s = serde_json::to_string(&m2).expect("serializes");
let back: RunManifest = serde_json::from_str(&s).expect("round-trips");
assert_eq!(back.project, Some(p));
}
#[test]
fn family_selection_round_trips_on_the_manifest() {
let sel = FamilySelection {
@@ -326,6 +367,7 @@ mod tests {
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
seed: 0, broker: "b".into(), selection: Some(sel.clone()), instrument: None,
topology_hash: None,
project: None,
};
let back: RunManifest = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
assert_eq!(back.selection, Some(sel));
@@ -466,6 +508,7 @@ mod tests {
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics,
}
@@ -571,6 +614,7 @@ mod tests {
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: RunMetrics {
total_pips: 12.0,
@@ -602,6 +646,7 @@ mod tests {
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, bias_sign_flips: 1, r: None },
};
@@ -625,6 +670,7 @@ mod tests {
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, bias_sign_flips: 1, r: None },
};
+1
View File
@@ -670,6 +670,7 @@ mod tests {
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: summarize(&equity, &exposure),
}
+1
View File
@@ -294,6 +294,7 @@ mod tests {
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: summarize(&[], &[]),
}
@@ -118,6 +118,7 @@ fn run_point(point: &[Cell]) -> RunReport {
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: summarize(&equity, &exposure),
}