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
Generated
+71
View File
@@ -118,9 +118,11 @@ dependencies = [
"clap", "clap",
"data-server", "data-server",
"libc", "libc",
"libloading",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"toml",
] ]
[[package]] [[package]]
@@ -622,6 +624,16 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.32" version = "0.4.32"
@@ -840,6 +852,15 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.6" version = "0.10.6"
@@ -948,6 +969,47 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.1" version = "1.20.1"
@@ -1094,6 +1156,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"
+2
View File
@@ -40,6 +40,8 @@ libc = "0.2"
# SHA256 user-settled); lives in the research-side CLI, off the frozen engine # SHA256 user-settled); lives in the research-side CLI, off the frozen engine
# (invariant 8). # (invariant 8).
sha2 = "0.10" sha2 = "0.10"
libloading = "0.8"
toml = "0.8"
# clap: the vetted standard argument parser. Adopted (C16 per-case review) to # clap: the vetted standard argument parser. Adopted (C16 per-case review) to
# replace the hand-rolled argv parser + ten duplicated help surfaces with one # replace the hand-rolled argv parser + ten duplicated help surfaces with one
# declarative source, giving scoped --help, --version, --flag=value, and # declarative source, giving scoped --help, --version, --flag=value, and
+29 -24
View File
@@ -7,9 +7,13 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use aura_engine::{blueprint_to_json, replay, BindOpError, GraphSession, Op, OpError, Scalar, ScalarKind}; use aura_engine::{blueprint_to_json, replay, BindOpError, GraphSession, Op, OpError, Scalar, ScalarKind};
use aura_std::{std_vocabulary, std_vocabulary_types};
use serde::Deserialize; use serde::Deserialize;
// `std_vocabulary` is now only reached through `crate::project::Env::resolve` in
// production code; tests still exercise it directly.
#[cfg(test)]
use aura_std::std_vocabulary;
/// The wire DTO for one construction op — the document's by-identifier shape, /// The wire DTO for one construction op — the document's by-identifier shape,
/// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind /// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind
/// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized /// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized
@@ -106,11 +110,11 @@ fn format_op_error(e: &OpError) -> String {
/// the emitted #155 blueprint JSON — or a `op N (kind): cause` message (a per-op /// the emitted #155 blueprint JSON — or a `op N (kind): cause` message (a per-op
/// fault, attributed by the retained op-kind list) / `finalize: cause` (a /// fault, attributed by the retained op-kind list) / `finalize: cause` (a
/// holistic fault past the last op). /// holistic fault past the last op).
pub fn build_from_str(doc: &str) -> Result<String, String> { pub fn build_from_str(doc: &str, env: &crate::project::Env) -> Result<String, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect(); let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect(); let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
match replay("graph", ops, &std_vocabulary) { match replay("graph", ops, &|t| env.resolve(t)) {
Ok(composite) => blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")), Ok(composite) => blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")),
Err((idx, err)) => { Err((idx, err)) => {
let cause = format_op_error(&err); let cause = format_op_error(&err);
@@ -124,14 +128,14 @@ pub fn build_from_str(doc: &str) -> Result<String, String> {
/// `aura graph build`: read the op-list document from stdin, build, and print the /// `aura graph build`: read the op-list document from stdin, build, and print the
/// blueprint to stdout — or the cause to stderr and exit non-zero. /// blueprint to stdout — or the cause to stderr and exit non-zero.
pub fn build_cmd() { pub fn build_cmd(env: &crate::project::Env) {
use std::io::Read; use std::io::Read;
let mut doc = String::new(); let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) { if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
eprintln!("aura: reading stdin: {e}"); eprintln!("aura: reading stdin: {e}");
std::process::exit(1); std::process::exit(1);
} }
match build_from_str(&doc) { match build_from_str(&doc, env) {
// `print!`, not `println!`: the canonical artifact is exactly the library // `print!`, not `println!`: the canonical artifact is exactly the library
// `blueprint_to_json` bytes (the form #158 content-addresses) — a JSON value // `blueprint_to_json` bytes (the form #158 content-addresses) — a JSON value
// with no trailing newline. The CLI is a transport; it must not frame the // with no trailing newline. The CLI is a transport; it must not frame the
@@ -147,8 +151,8 @@ pub fn build_cmd() {
/// `aura graph introspect --node <T>`: a type's ports + kinds + param paths, /// `aura graph introspect --node <T>`: a type's ports + kinds + param paths,
/// read off the pre-build schema (no graph built). `Err` if `T` is not in the /// read off the pre-build schema (no graph built). `Err` if `T` is not in the
/// closed vocabulary. /// closed vocabulary.
pub fn introspect_node(type_id: &str) -> Result<String, String> { pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<String, String> {
let builder = std_vocabulary(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?; let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
let schema = builder.schema(); let schema = builder.schema();
let mut out = format!("{}\n", builder.label()); let mut out = format!("{}\n", builder.label());
for port in &schema.inputs { for port in &schema.inputs {
@@ -165,9 +169,10 @@ pub fn introspect_node(type_id: &str) -> Result<String, String> {
/// `aura graph introspect --unwired`: the still-open interior slots of a partial /// `aura graph introspect --unwired`: the still-open interior slots of a partial
/// op-list document, by-identifier (applies the ops, does NOT finalize). /// op-list document, by-identifier (applies the ops, does NOT finalize).
pub fn introspect_unwired(doc: &str) -> Result<String, String> { pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let mut session = GraphSession::new("introspect", &std_vocabulary); let resolver = |t: &str| env.resolve(t);
let mut session = GraphSession::new("introspect", &resolver);
for (i, d) in docs.into_iter().enumerate() { for (i, d) in docs.into_iter().enumerate() {
session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?; session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?;
} }
@@ -181,7 +186,7 @@ pub fn introspect_unwired(doc: &str) -> Result<String, String> {
/// `aura graph introspect`: dispatch the read-only queries. Exactly one of /// `aura graph introspect`: dispatch the read-only queries. Exactly one of
/// `--vocabulary` / `--node <T>` / `--unwired` / `--content-id` must be set; /// `--vocabulary` / `--node <T>` / `--unwired` / `--content-id` must be set;
/// zero or more than one is the usage error (exit 2). /// zero or more than one is the usage error (exit 2).
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) { pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) {
let count = cmd.vocabulary as usize let count = cmd.vocabulary as usize
+ cmd.node.is_some() as usize + cmd.node.is_some() as usize
+ cmd.unwired as usize + cmd.unwired as usize
@@ -193,11 +198,11 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) {
std::process::exit(2); std::process::exit(2);
} }
if cmd.vocabulary { if cmd.vocabulary {
for t in std_vocabulary_types() { for t in env.type_ids() {
println!("{t}"); println!("{t}");
} }
} else if let Some(type_id) = cmd.node.as_deref() { } else if let Some(type_id) = cmd.node.as_deref() {
match introspect_node(type_id) { match introspect_node(type_id, env) {
Ok(s) => print!("{s}"), Ok(s) => print!("{s}"),
Err(m) => { Err(m) => {
eprintln!("aura: {m}"); eprintln!("aura: {m}");
@@ -211,7 +216,7 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) {
eprintln!("aura: reading stdin: {e}"); eprintln!("aura: reading stdin: {e}");
std::process::exit(1); std::process::exit(1);
} }
match introspect_unwired(&doc) { match introspect_unwired(&doc, env) {
Ok(s) => print!("{s}"), Ok(s) => print!("{s}"),
Err(m) => { Err(m) => {
eprintln!("aura: {m}"); eprintln!("aura: {m}");
@@ -229,7 +234,7 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd) {
eprintln!("aura: reading stdin: {e}"); eprintln!("aura: reading stdin: {e}");
std::process::exit(1); std::process::exit(1);
} }
match build_from_str(&doc) { match build_from_str(&doc, env) {
Ok(json) => println!("{}", crate::content_id(&json)), Ok(json) => println!("{}", crate::content_id(&json)),
Err(m) => { Err(m) => {
eprintln!("aura: {m}"); eprintln!("aura: {m}");
@@ -283,7 +288,7 @@ mod tests {
{"op":"connect","from":"sub.value","to":"bias.signal"}, {"op":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"} {"op":"expose","from":"bias.bias","as":"bias"}
]"#; ]"#;
let json = super::build_from_str(doc).expect("valid document builds"); let json = super::build_from_str(doc, &crate::project::Env::std()).expect("valid document builds");
assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}"); assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}");
assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}"); assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}");
} }
@@ -299,14 +304,14 @@ mod tests {
{"op":"feed","role":"price","into":["fast.series"]}, {"op":"feed","role":"price","into":["fast.series"]},
{"op":"expose","from":"fast.value","as":"out"} {"op":"expose","from":"fast.value","as":"out"}
]"#; ]"#;
let json = super::build_from_str(doc).expect("name-keyed add builds"); let json = super::build_from_str(doc, &crate::project::Env::std()).expect("name-keyed add builds");
assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}"); assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}");
} }
#[test] #[test]
fn build_from_str_reports_unknown_node_at_its_op_index() { fn build_from_str_reports_unknown_node_at_its_op_index() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#; let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#;
let err = super::build_from_str(doc).unwrap_err(); let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
assert_eq!(err, "op 1 (add): unknown node type \"Nope\""); assert_eq!(err, "op 1 (add): unknown node type \"Nope\"");
} }
@@ -321,7 +326,7 @@ mod tests {
{"op":"feed","role":"price","into":["fast.series","slow.series"]}, {"op":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"} {"op":"connect","from":"fast.value","to":"sub.lhs"}
]"#; ]"#;
let err = super::build_from_str(doc).unwrap_err(); let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
assert!(err.starts_with("finalize: "), "finalize fault, got: {err}"); assert!(err.starts_with("finalize: "), "finalize fault, got: {err}");
assert!(err.contains("sub.rhs") && err.contains("is unconnected"), assert!(err.contains("sub.rhs") && err.contains("is unconnected"),
"names the unconnected slot by-identifier, got: {err}"); "names the unconnected slot by-identifier, got: {err}");
@@ -330,11 +335,11 @@ mod tests {
#[test] #[test]
fn introspect_node_lists_ports_kinds_and_params() { fn introspect_node_lists_ports_kinds_and_params() {
let out = super::introspect_node("SMA").expect("SMA is in the vocabulary"); let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
assert!(out.contains("series"), "lists the input port: {out}"); assert!(out.contains("series"), "lists the input port: {out}");
assert!(out.contains("value"), "lists the output field: {out}"); assert!(out.contains("value"), "lists the output field: {out}");
assert!(out.contains("length"), "lists the param path: {out}"); assert!(out.contains("length"), "lists the param path: {out}");
assert!(super::introspect_node("Nope").is_err(), "rejects an unknown type"); assert!(super::introspect_node("Nope", &crate::project::Env::std()).is_err(), "rejects an unknown type");
} }
/// A bind whose value-kind mismatches the param reads as prose, like the connect /// A bind whose value-kind mismatches the param reads as prose, like the connect
@@ -342,7 +347,7 @@ mod tests {
#[test] #[test]
fn build_from_str_bad_bind_kind_reads_as_prose() { fn build_from_str_bad_bind_kind_reads_as_prose() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#; let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#;
let err = super::build_from_str(doc).unwrap_err(); let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64"); assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64");
} }
@@ -351,7 +356,7 @@ mod tests {
#[test] #[test]
fn build_from_str_unknown_bind_param_reads_as_prose() { fn build_from_str_unknown_bind_param_reads_as_prose() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#; let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#;
let err = super::build_from_str(doc).unwrap_err(); let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
assert_eq!(err, "op 0 (add): node fast has no param \"window\""); assert_eq!(err, "op 0 (add): node fast has no param \"window\"");
} }
@@ -372,7 +377,7 @@ mod tests {
/// does not have to read source to learn the `{"I64": <v>}` wrapping. /// does not have to read source to learn the `{"I64": <v>}` wrapping.
#[test] #[test]
fn introspect_node_shows_the_bind_form() { fn introspect_node_shows_the_bind_form() {
let out = super::introspect_node("SMA").expect("SMA is in the vocabulary"); let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
assert!(out.contains("(bind {\"I64\": <v>})"), "shows the bind-value form: {out}"); assert!(out.contains("(bind {\"I64\": <v>})"), "shows the bind-value form: {out}");
} }
@@ -383,7 +388,7 @@ mod tests {
{"op":"add","type":"Sub"}, {"op":"add","type":"Sub"},
{"op":"connect","from":"fast.value","to":"sub.lhs"} {"op":"connect","from":"fast.value","to":"sub.lhs"}
]"#; ]"#;
let out = super::introspect_unwired(doc).expect("partial document introspects"); let out = super::introspect_unwired(doc, &crate::project::Env::std()).expect("partial document introspects");
assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}"); assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}");
assert!(out.contains("fast.series"), "fast.series is still open: {out}"); assert!(out.contains("fast.series"), "fast.series is still open: {out}");
assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}"); assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}");
File diff suppressed because it is too large Load Diff
+589
View File
@@ -0,0 +1,589 @@
//! Project discovery + the cdylib load boundary (cycle 0102, C13/C16).
//!
//! `discover_from` walks up to the nearest `Aura.toml` (the way cargo finds
//! `Cargo.toml`); `load` locates the project's cdylib via `cargo metadata`,
//! loads it **load-and-hold** (leaked, never unloaded), validates the C tier
//! of the descriptor BEFORE touching any Rust-ABI field, then applies the
//! vocabulary charter. `Env` is the per-invocation context every verb reads:
//! merged resolver, runs root, data path, provenance.
use aura_core::PrimitiveBuilder;
use aura_core::project::{
AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL,
CORE_VERSION, ProjectDescriptor, RUSTC_VERSION, StrSlice,
};
use aura_engine::ProjectProvenance;
use aura_registry::{Registry, TraceStore};
use aura_std::{std_vocabulary, std_vocabulary_types};
use sha2::{Digest, Sha256};
use std::fmt;
use std::path::{Path, PathBuf};
/// Parsed `Aura.toml` — static project context only (C17): paths, nothing else.
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
pub struct AuraToml {
#[serde(default)]
pub paths: AuraPaths,
}
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
pub struct AuraPaths {
/// Data archive root; default: the data-server default.
#[serde(default)]
pub data: Option<String>,
/// Registry root, relative to the project root; default `"runs"`.
#[serde(default)]
pub runs: Option<PathBuf>,
}
#[derive(Debug)]
pub enum ProjectError {
Toml(PathBuf, String),
CargoMetadata(String),
ArtifactMissing(PathBuf),
Load(PathBuf, String),
NotAProjectDylib(PathBuf),
Incompatible { what: &'static str, dylib: String, host: String },
Charter(String),
}
impl fmt::Display for ProjectError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Toml(p, e) => write!(f, "failed to parse {}: {e}", p.display()),
Self::CargoMetadata(e) => write!(f, "cargo metadata failed: {e}"),
Self::ArtifactMissing(p) => write!(
f,
"project dylib not found at {} — run `cargo build` in the \
project first (or pass --release to load the release build)",
p.display()
),
Self::Load(p, e) => write!(f, "failed to load {}: {e}", p.display()),
Self::NotAProjectDylib(p) => write!(
f,
"{} exports no valid AURA_PROJECT descriptor (not an aura \
project dylib, or an incompatible descriptor layout)",
p.display()
),
Self::Incompatible { what, dylib, host } => write!(
f,
"project dylib is ABI-incompatible: {what} mismatch \
(dylib: {dylib}, host: {host}) — rebuild the project with \
the host's toolchain/aura-core"
),
Self::Charter(e) => write!(f, "project vocabulary rejected: {e}"),
}
}
}
/// A successfully loaded project. The `Library` behind `resolver`/`type_ids`
/// is leaked (load-and-hold): the fn pointers are valid for 'static.
pub struct ProjectEnv {
pub root: PathBuf,
pub toml: AuraToml,
pub namespace: String,
pub dylib_sha256: String,
pub commit: Option<String>,
resolver: fn(&str) -> Option<PrimitiveBuilder>,
type_id_list: &'static [&'static str],
}
impl ProjectEnv {
fn resolve(&self, type_id: &str) -> Option<PrimitiveBuilder> {
(self.resolver)(type_id)
}
}
/// The per-invocation context every verb reads. Outside a project every
/// accessor collapses to today's literal defaults (byte-identical behaviour).
pub struct Env {
project: Option<ProjectEnv>,
}
impl Env {
pub fn std() -> Self {
Self { project: None }
}
pub fn with_project(p: ProjectEnv) -> Self {
Self { project: Some(p) }
}
/// The one resolver every verb consumes: project first, then std. The
/// charter makes overlap impossible, so order is not load-bearing.
pub fn resolve(&self, type_id: &str) -> Option<PrimitiveBuilder> {
match &self.project {
Some(p) => p.resolve(type_id).or_else(|| std_vocabulary(type_id)),
None => std_vocabulary(type_id),
}
}
/// project std type ids, project first (for `introspect --vocabulary`).
pub fn type_ids(&self) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
if let Some(p) = &self.project {
out.extend_from_slice(p.type_id_list);
}
out.extend_from_slice(std_vocabulary_types());
out
}
/// The registry root: `<project>/<paths.runs|"runs">` inside a project,
/// the literal `runs` outside (today's behaviour).
pub fn runs_root(&self) -> PathBuf {
match &self.project {
Some(p) => {
let runs = p.toml.paths.runs.clone().unwrap_or_else(|| "runs".into());
p.root.join(runs)
}
None => PathBuf::from("runs"),
}
}
pub fn registry(&self) -> Registry {
Registry::open(self.runs_root().join("runs.jsonl"))
}
pub fn trace_store(&self) -> TraceStore {
TraceStore::open(self.runs_root())
}
/// The data archive root: `paths.data` inside a project when set,
/// the data-server default otherwise.
pub fn data_path(&self) -> String {
self.project
.as_ref()
.and_then(|p| p.toml.paths.data.clone())
.unwrap_or_else(|| data_server::DEFAULT_DATA_PATH.to_string())
}
pub fn provenance(&self) -> Option<ProjectProvenance> {
self.project.as_ref().map(|p| ProjectProvenance {
namespace: p.namespace.clone(),
dylib_sha256: p.dylib_sha256.clone(),
commit: p.commit.clone(),
})
}
}
/// Walk up from `start` to the nearest directory containing `Aura.toml`.
pub fn discover_from(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start);
while let Some(d) = dir {
if d.join("Aura.toml").is_file() {
return Some(d.to_path_buf());
}
dir = d.parent();
}
None
}
fn parse_aura_toml(root: &Path) -> Result<AuraToml, ProjectError> {
let path = root.join("Aura.toml");
let text = std::fs::read_to_string(&path)
.map_err(|e| ProjectError::Toml(path.clone(), e.to_string()))?;
toml::from_str(&text).map_err(|e| ProjectError::Toml(path, e.to_string()))
}
/// Derive the cdylib artifact path from `cargo metadata` output (pure —
/// unit-testable on a canned JSON document).
fn artifact_from_metadata(
metadata_json: &str,
release: bool,
) -> Result<PathBuf, ProjectError> {
let v: serde_json::Value = serde_json::from_str(metadata_json)
.map_err(|e| ProjectError::CargoMetadata(e.to_string()))?;
let target_dir = v["target_directory"]
.as_str()
.ok_or_else(|| ProjectError::CargoMetadata("no target_directory".into()))?;
let packages = v["packages"]
.as_array()
.ok_or_else(|| ProjectError::CargoMetadata("no packages".into()))?;
let lib_name = packages
.iter()
.flat_map(|p| p["targets"].as_array().into_iter().flatten())
.find(|t| {
t["kind"]
.as_array()
.is_some_and(|k| k.iter().any(|s| s.as_str() == Some("cdylib")))
})
.and_then(|t| t["name"].as_str())
.ok_or_else(|| {
ProjectError::CargoMetadata(
"no cdylib target in the project crate (is `crate-type = \
[\"cdylib\"]` set?)"
.into(),
)
})?;
let profile = if release { "release" } else { "debug" };
let file = format!(
"{}{}{}",
std::env::consts::DLL_PREFIX,
lib_name.replace('-', "_"),
std::env::consts::DLL_SUFFIX
);
Ok(Path::new(target_dir).join(profile).join(file))
}
fn artifact_path(root: &Path, release: bool) -> Result<PathBuf, ProjectError> {
let out = std::process::Command::new("cargo")
.args(["metadata", "--format-version", "1", "--no-deps"])
.current_dir(root)
.output()
.map_err(|e| ProjectError::CargoMetadata(e.to_string()))?;
if !out.status.success() {
return Err(ProjectError::CargoMetadata(
String::from_utf8_lossy(&out.stderr).trim().to_string(),
));
}
artifact_from_metadata(&String::from_utf8_lossy(&out.stdout), release)
}
/// The vocabulary charter (decision log on the milestone reference issue):
/// non-empty namespace; every listed id `\<ns\>::`-prefixed; no duplicate in
/// the merged project std set; every listed id resolves (list↔resolver
/// cross-check). Pure — unit-testable without a dylib.
fn check_charter(
namespace: &str,
ids: &[&str],
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
) -> Result<(), ProjectError> {
if namespace.is_empty() {
return Err(ProjectError::Charter("empty namespace".into()));
}
let prefix = format!("{namespace}::");
let mut seen = std::collections::BTreeSet::new();
for id in ids {
if !id.starts_with(&prefix) {
return Err(ProjectError::Charter(format!(
"type id `{id}` lacks the project prefix `{prefix}`"
)));
}
if !seen.insert(*id) {
return Err(ProjectError::Charter(format!("duplicate type id `{id}`")));
}
if std_vocabulary(id).is_some() || std_vocabulary_types().contains(id) {
return Err(ProjectError::Charter(format!(
"type id `{id}` collides with the std vocabulary"
)));
}
if resolve(id).is_none() {
return Err(ProjectError::Charter(format!(
"listed type id `{id}` does not resolve through the project \
resolver (list/resolver drift)"
)));
}
}
Ok(())
}
fn project_commit(root: &Path) -> Option<String> {
let head = std::process::Command::new("git")
.args(["-C"])
.arg(root)
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())?;
let mut sha = String::from_utf8_lossy(&head.stdout).trim().to_string();
let dirty = std::process::Command::new("git")
.args(["-C"])
.arg(root)
.args(["status", "--porcelain"])
.output()
.ok()
.filter(|o| o.status.success())?;
if !dirty.stdout.is_empty() {
sha.push_str("-dirty");
}
Some(sha)
}
/// Validate the C tier of a descriptor's already-extracted fields — pure
/// value checks, no dylib/pointer work (the raw reads happen once, in
/// `load`, before this runs). Front-to-back: magic, descriptor version,
/// rustc stamp, aura-core stamp, then the namespace. Returns the namespace
/// on success. Unit-testable without a dylib by constructing the fields
/// directly, the same way `check_charter` is testable without a resolver.
fn validate_c_tier(
magic: u64,
descriptor_version: u32,
rustc_version: StrSlice,
aura_core_version: StrSlice,
namespace: StrSlice,
dylib_path: &Path,
) -> Result<String, ProjectError> {
if magic != AURA_DESCRIPTOR_MAGIC {
return Err(ProjectError::NotAProjectDylib(dylib_path.to_path_buf()));
}
if descriptor_version != AURA_DESCRIPTOR_VERSION {
return Err(ProjectError::Incompatible {
what: "descriptor version",
dylib: descriptor_version.to_string(),
host: AURA_DESCRIPTOR_VERSION.to_string(),
});
}
let dylib_rustc = unsafe { rustc_version.as_str() }
.ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?;
if dylib_rustc != RUSTC_VERSION {
return Err(ProjectError::Incompatible {
what: "rustc version",
dylib: dylib_rustc.to_string(),
host: RUSTC_VERSION.to_string(),
});
}
let dylib_core = unsafe { aura_core_version.as_str() }
.ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))?;
if dylib_core != CORE_VERSION {
return Err(ProjectError::Incompatible {
what: "aura-core version",
dylib: dylib_core.to_string(),
host: CORE_VERSION.to_string(),
});
}
unsafe { namespace.as_str() }
.ok_or_else(|| ProjectError::NotAProjectDylib(dylib_path.to_path_buf()))
.map(str::to_string)
}
/// Locate, load (load-and-hold), verify, and charter-check the project dylib.
pub fn load(root: &Path, release: bool) -> Result<ProjectEnv, ProjectError> {
let toml = parse_aura_toml(root)?;
let dylib_path = artifact_path(root, release)?;
if !dylib_path.is_file() {
return Err(ProjectError::ArtifactMissing(dylib_path));
}
let bytes = std::fs::read(&dylib_path)
.map_err(|e| ProjectError::Load(dylib_path.clone(), e.to_string()))?;
let dylib_sha256 = format!("{:x}", Sha256::digest(&bytes));
// Load-and-hold: leak the Library so every pointer read from the
// descriptor is valid for 'static. There is deliberately no unload path
// (one-shot process; ledger C13 note).
let lib = unsafe { libloading::Library::new(&dylib_path) }
.map_err(|e| ProjectError::Load(dylib_path.clone(), e.to_string()))?;
let lib: &'static libloading::Library = Box::leak(Box::new(lib));
let sym = unsafe { lib.get::<*const ProjectDescriptor>(AURA_PROJECT_SYMBOL) }
.map_err(|_| ProjectError::NotAProjectDylib(dylib_path.clone()))?;
let desc_ptr: *const ProjectDescriptor = *sym;
if desc_ptr.is_null() {
return Err(ProjectError::NotAProjectDylib(dylib_path));
}
// ---- C tier, validated front-to-back; no Rust-ABI field touched yet ----
let magic = unsafe { (*desc_ptr).magic };
let descriptor_version = unsafe { (*desc_ptr).descriptor_version };
let rustc_version = unsafe { (*desc_ptr).rustc_version };
let aura_core_version = unsafe { (*desc_ptr).aura_core_version };
let namespace_stamp = unsafe { (*desc_ptr).namespace };
let namespace = validate_c_tier(
magic,
descriptor_version,
rustc_version,
aura_core_version,
namespace_stamp,
&dylib_path,
)?;
// ---- Rust tier: stamps match, the ABI is trusted from here on ----
let desc: &'static ProjectDescriptor = unsafe { &*desc_ptr };
let resolver = desc.vocabulary;
let type_id_list = (desc.type_ids)();
check_charter(&namespace, type_id_list, &|t| resolver(t))?;
Ok(ProjectEnv {
root: root.to_path_buf(),
toml,
namespace,
dylib_sha256,
commit: project_commit(root),
resolver,
type_id_list,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discover_walks_up_to_aura_toml() {
let tmp = std::env::temp_dir().join(format!("aura-disc-{}", std::process::id()));
let nested = tmp.join("a/b/c");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(discover_from(&nested), None);
std::fs::write(tmp.join("Aura.toml"), "").unwrap();
assert_eq!(discover_from(&nested), Some(tmp.clone()));
assert_eq!(discover_from(&tmp), Some(tmp.clone()));
std::fs::remove_dir_all(&tmp).unwrap();
}
#[test]
fn aura_toml_parses_empty_partial_and_unknown_keys() {
let t: AuraToml = toml::from_str("").unwrap();
assert_eq!(t, AuraToml::default());
let t: AuraToml = toml::from_str("[paths]\nruns = \"r\"").unwrap();
assert_eq!(t.paths.runs, Some(PathBuf::from("r")));
assert_eq!(t.paths.data, None);
// unknown keys tolerated (forward-compat; serde default is lenient)
let t: AuraToml = toml::from_str("[future]\nx = 1").unwrap();
assert_eq!(t, AuraToml::default());
}
#[test]
fn artifact_path_derives_from_metadata_json() {
let json = r#"{
"target_directory": "/tmp/proj/target",
"packages": [{"targets": [
{"kind": ["cdylib"], "name": "demo-project"}
]}]
}"#;
let p = artifact_from_metadata(json, false).unwrap();
let expect = format!(
"/tmp/proj/target/debug/{}demo_project{}",
std::env::consts::DLL_PREFIX,
std::env::consts::DLL_SUFFIX
);
assert_eq!(p, PathBuf::from(expect));
let p = artifact_from_metadata(json, true).unwrap();
assert!(p.to_string_lossy().contains("/release/"));
// no cdylib target -> named error
let bad = r#"{"target_directory":"/t","packages":[{"targets":[{"kind":["lib"],"name":"x"}]}]}"#;
assert!(matches!(
artifact_from_metadata(bad, false),
Err(ProjectError::CargoMetadata(_))
));
}
fn none_resolver(_: &str) -> Option<PrimitiveBuilder> {
None
}
#[test]
fn charter_rejects_each_violation_and_accepts_valid() {
let ok_resolver = |t: &str| {
if t == "p::A" || t == "p::B" { std_vocabulary("SMA") } else { None }
};
// valid
assert!(check_charter("p", &["p::A", "p::B"], &ok_resolver).is_ok());
// empty namespace
assert!(matches!(
check_charter("", &[], &none_resolver),
Err(ProjectError::Charter(_))
));
// unprefixed id
let e = check_charter("p", &["A"], &ok_resolver).unwrap_err();
assert!(e.to_string().contains("lacks the project prefix"));
// duplicate id
let e = check_charter("p", &["p::A", "p::A"], &ok_resolver).unwrap_err();
assert!(e.to_string().contains("duplicate"));
// list/resolver drift
let e = check_charter("p", &["p::C"], &ok_resolver).unwrap_err();
assert!(e.to_string().contains("does not resolve"));
}
fn matching_stamps() -> (u64, u32, StrSlice, StrSlice) {
(
AURA_DESCRIPTOR_MAGIC,
AURA_DESCRIPTOR_VERSION,
StrSlice::new(RUSTC_VERSION),
StrSlice::new(CORE_VERSION),
)
}
#[test]
fn validate_c_tier_accepts_matching_stamps() {
let (magic, version, rustc, core) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let ns = validate_c_tier(magic, version, rustc, core, StrSlice::new("demo"), &path)
.unwrap();
assert_eq!(ns, "demo");
}
/// A magic mismatch means the symbol is not an `AURA_PROJECT` descriptor
/// at all (foreign dylib) — refused as `NotAProjectDylib`, not
/// `Incompatible` (there is no known-good stamp to compare against).
#[test]
fn validate_c_tier_rejects_magic_mismatch() {
let (_, version, rustc, core) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let err = validate_c_tier(0xdead_beef, version, rustc, core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(err, ProjectError::NotAProjectDylib(_)));
}
#[test]
fn validate_c_tier_rejects_descriptor_version_mismatch() {
let (magic, _, rustc, core) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let err = validate_c_tier(magic, 99, rustc, core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(
err,
ProjectError::Incompatible { what: "descriptor version", .. }
));
}
#[test]
fn validate_c_tier_rejects_rustc_version_mismatch() {
let (magic, version, _, core) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let bad_rustc = StrSlice::new("rustc 0.0.0-fake");
let err = validate_c_tier(magic, version, bad_rustc, core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(
err,
ProjectError::Incompatible { what: "rustc version", .. }
));
}
#[test]
fn validate_c_tier_rejects_aura_core_version_mismatch() {
let (magic, version, rustc, _) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let bad_core = StrSlice::new("9.9.9-fake");
let err = validate_c_tier(magic, version, rustc, bad_core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(
err,
ProjectError::Incompatible { what: "aura-core version", .. }
));
}
/// A null stamp pointer (e.g. a zeroed/corrupt descriptor) refuses rather
/// than dereferencing it, same as the standalone `StrSlice::as_str` test
/// in aura-core — here exercised through the loader's own refusal path.
#[test]
fn validate_c_tier_rejects_null_stamp() {
let (magic, version, _, core) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
let null_rustc = StrSlice { ptr: std::ptr::null(), len: 0 };
let err = validate_c_tier(magic, version, null_rustc, core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(err, ProjectError::NotAProjectDylib(_)));
}
/// A non-UTF-8 stamp (foreign/corrupt bytes at the pointer) refuses
/// rather than mangling a comparison or panicking.
#[test]
fn validate_c_tier_rejects_non_utf8_stamp() {
let (magic, version, rustc, _) = matching_stamps();
let path = PathBuf::from("/tmp/x.so");
static BAD: [u8; 2] = [0xFF, 0xFE];
let bad_core = StrSlice { ptr: BAD.as_ptr(), len: BAD.len() };
let err = validate_c_tier(magic, version, rustc, bad_core, StrSlice::new("demo"), &path)
.unwrap_err();
assert!(matches!(err, ProjectError::NotAProjectDylib(_)));
}
#[test]
fn env_std_collapses_to_current_defaults() {
let env = Env::std();
assert_eq!(env.runs_root(), PathBuf::from("runs"));
assert_eq!(env.data_path(), data_server::DEFAULT_DATA_PATH.to_string());
assert!(env.provenance().is_none());
assert!(env.resolve("SMA").is_some());
assert!(env.resolve("demo::Identity").is_none());
assert!(env.type_ids().contains(&"SMA"));
}
}
@@ -0,0 +1,3 @@
/target
Cargo.lock
/runs
@@ -0,0 +1 @@
# badcharter fixture — static project context only (C17); paths only (cycle 0102).
@@ -0,0 +1,15 @@
[package]
name = "badcharter-project"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
aura-core = { path = "../../../../aura-core" }
# Standalone workspace root: never a member of the engine workspace
# (docs/project-layout.md, the empty-[workspace] note).
[workspace]
@@ -0,0 +1,23 @@
//! Fixture project for the vocabulary-charter e2e (cycle 0102): deliberately
//! lists a type id that is NOT `<namespace>::`-prefixed, to prove the charter
//! refusal fires through the real `aura-cli::project::load` path (libloading +
//! descriptor read), not merely through the pure `check_charter` unit function.
//! The resolver never needs to resolve anything: the prefix check runs before
//! the list/resolver cross-check, so `vocabulary` can stay a constant `None`.
use aura_core::PrimitiveBuilder;
fn vocabulary(_type_id: &str) -> Option<PrimitiveBuilder> {
None
}
/// Missing the required `badns::` prefix — the charter violation under test.
fn type_ids() -> &'static [&'static str] {
&["Bad"]
}
aura_core::aura_project! {
namespace: "badns",
vocabulary: vocabulary,
type_ids: type_ids,
}
@@ -0,0 +1,3 @@
/target
Cargo.lock
/runs
+3
View File
@@ -0,0 +1,3 @@
# demo fixture — static project context only (C17); paths only (cycle 0102).
[paths]
runs = "runs"
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "demo-project"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
aura-core = { path = "../../../../aura-core" }
# Standalone workspace root: never a member of the engine workspace
# (docs/project-layout.md, the empty-[workspace] note).
[workspace]
@@ -0,0 +1 @@
{"format_version":1,"blueprint":{"name":"demo_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"demo::Identity"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}
+73
View File
@@ -0,0 +1,73 @@
//! Fixture project for the load-boundary e2e (cycle 0102): one pass-through
//! node under the `demo` namespace, exported via the descriptor macro.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// One-input f64 pass-through. Emits `None` until its input has a value.
pub struct Identity {
out: [Cell; 1],
}
impl Identity {
pub fn new() -> Self {
Self { out: [Cell::from_f64(0.0)] }
}
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"demo::Identity",
NodeSchema {
inputs: vec![PortSpec {
kind: ScalarKind::F64,
firing: Firing::Any,
name: "value".into(),
}],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
},
|_| Box::new(Identity::new()),
)
}
}
impl Default for Identity {
fn default() -> Self {
Self::new()
}
}
impl Node for Identity {
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None;
}
self.out[0] = Cell::from_f64(w[0]);
Some(&self.out)
}
fn label(&self) -> String {
"demo::Identity".to_string()
}
}
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
match type_id {
"demo::Identity" => Some(Identity::builder()),
_ => None,
}
}
fn type_ids() -> &'static [&'static str] {
&["demo::Identity"]
}
aura_core::aura_project! {
namespace: "demo",
vocabulary: vocabulary,
type_ids: type_ids,
}
+185
View File
@@ -0,0 +1,185 @@
//! E2E: the project-as-crate load boundary (cycle 0102). Builds the fixture
//! project once (cargo, same toolchain, path-dep on this workspace's
//! aura-core), then drives the aura binary from inside the fixture dir.
//! `aura run` persists nothing without --trace, so the tracked fixture stays
//! clean (target/, Cargo.lock, runs/ are fixture-gitignored).
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::OnceLock;
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo-project")
}
fn badcharter_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/badcharter-project")
}
fn built_fixture() -> &'static PathBuf {
static BUILT: OnceLock<PathBuf> = OnceLock::new();
BUILT.get_or_init(|| {
let dir = fixture_dir();
let out = Command::new("cargo")
.arg("build")
.current_dir(&dir)
.output()
.expect("spawn cargo build for the fixture project");
assert!(
out.status.success(),
"fixture build failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
dir
})
}
fn aura(args: &[&str], cwd: &Path) -> Output {
Command::new(env!("CARGO_BIN_EXE_aura"))
.args(args)
.current_dir(cwd)
.output()
.expect("run aura")
}
#[test]
fn project_run_resolves_demo_node_and_is_bit_identical() {
let dir = built_fixture();
let a = aura(&["run", "demo_signal.json"], dir);
assert!(
a.status.success(),
"run failed: {}",
String::from_utf8_lossy(&a.stderr)
);
let b = aura(&["run", "demo_signal.json"], dir);
assert!(b.status.success());
assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)");
}
#[test]
fn project_run_manifest_carries_provenance() {
let dir = built_fixture();
let out = aura(&["run", "demo_signal.json"], dir);
assert!(out.status.success());
let v: serde_json::Value =
serde_json::from_slice(&out.stdout).expect("run report is JSON");
let p = &v["manifest"]["project"];
assert_eq!(p["namespace"], "demo");
let sha = p["dylib_sha256"].as_str().expect("hash present");
assert_eq!(sha.len(), 64, "sha256 hex");
}
#[test]
fn introspect_vocabulary_lists_project_types() {
let dir = built_fixture();
let out = aura(&["graph", "introspect", "--vocabulary"], dir);
assert!(out.status.success());
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("demo::Identity"), "project id listed");
assert!(text.contains("SMA"), "std ids still listed");
}
#[test]
fn outside_a_project_the_demo_blueprint_is_unknown() {
// cwd = the aura-cli crate dir: no Aura.toml anywhere up the tree.
let cwd = Path::new(env!("CARGO_MANIFEST_DIR"));
let bp = fixture_dir().join("demo_signal.json");
let out = aura(&["run", bp.to_str().unwrap()], cwd);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("UnknownNodeType"), "stderr: {err}");
}
/// `Env::runs_root()` resolves `Aura.toml`'s `[paths] runs = "runs"` against
/// the DISCOVERED project root (the `Aura.toml` directory found by walking up
/// from cwd), not against the invocation cwd itself: a family recorded while
/// invoking `aura` from a project subdirectory must still land under
/// `<project-root>/runs/`, and no stray `runs/` must appear under the
/// subdirectory. This is the property that makes `[paths]` project-relative
/// rather than shell-relative (C17).
#[test]
fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
let dir = built_fixture();
let sub = dir.join("src");
let runs_dir = dir.join("runs");
std::fs::remove_dir_all(&runs_dir).ok();
let open_bp =
format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR"));
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
.args([
"sweep",
&open_bp,
"--axis",
"sma_signal.fast.length=2,4",
"--axis",
"sma_signal.slow.length=8,16",
"--name",
"proj-anchor",
])
.current_dir(&sub)
.output()
.expect("spawn aura sweep");
assert!(
out.status.success(),
"sweep failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
runs_dir.join("families.jsonl").is_file(),
"family record must land at the project root's runs/, not the invocation cwd's"
);
assert!(
!sub.join("runs").exists(),
"no stray runs/ must be created under the invocation subdirectory"
);
std::fs::remove_dir_all(&runs_dir).ok();
}
/// A project whose listed type id is not `<namespace>::`-prefixed is refused
/// (exit 1, cause named) through the REAL load path — `libloading` +
/// descriptor read + `check_charter` wired together in `project::load` — not
/// merely the pure `check_charter` function (already unit-tested in
/// `aura-cli::project`). Pins the "refuse-don't-guess" vocabulary charter as
/// an end-to-end guarantee: a regression that skipped the charter call in the
/// `load()` glue would pass every existing unit test but still be caught here.
#[test]
fn vocabulary_charter_violation_refuses_end_to_end() {
let dir = badcharter_dir();
let build = Command::new("cargo")
.arg("build")
.current_dir(&dir)
.output()
.expect("spawn cargo build for the badcharter fixture");
assert!(
build.status.success(),
"badcharter fixture build failed:\n{}",
String::from_utf8_lossy(&build.stderr)
);
let run = aura(&["run", "x.json"], &dir);
assert_eq!(
run.status.code(),
Some(1),
"a charter violation is a runtime refusal (exit 1), not a usage error"
);
let err = String::from_utf8_lossy(&run.stderr);
assert!(err.contains("lacks the project prefix"), "stderr must name the charter cause: {err}");
}
#[test]
fn missing_artifact_refuses_with_build_hint() {
// A minimal never-built project: valid cargo metadata, no dylib on disk.
let tmp = std::env::temp_dir().join(format!("aura-nobuild-{}", std::process::id()));
std::fs::create_dir_all(tmp.join("src")).unwrap();
std::fs::write(tmp.join("Aura.toml"), "").unwrap();
std::fs::write(tmp.join("src/lib.rs"), "").unwrap();
std::fs::write(
tmp.join("Cargo.toml"),
"[package]\nname = \"nobuild\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[workspace]\n",
)
.unwrap();
let out = aura(&["run", "x.json"], &tmp);
assert_eq!(out.status.code(), Some(1), "runtime refusal");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("cargo build"), "build hint present: {err}");
std::fs::remove_dir_all(&tmp).ok();
}
+16
View File
@@ -0,0 +1,16 @@
//! Bakes the compiling toolchain's version into `AURA_RUSTC_VERSION` so the
//! project descriptor (src/project.rs) can stamp it. Runs per consuming build:
//! the host binary and a project cdylib each recompile aura-core under their
//! own toolchain, which is exactly what makes the two stamps comparable.
fn main() {
let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into());
let out = std::process::Command::new(&rustc)
.arg("--version")
.output()
.expect("rustc --version");
println!(
"cargo:rustc-env=AURA_RUSTC_VERSION={}",
String::from_utf8_lossy(&out.stdout).trim()
);
}
+1
View File
@@ -34,6 +34,7 @@ mod column;
mod ctx; mod ctx;
mod error; mod error;
mod node; mod node;
pub mod project;
mod scalar; mod scalar;
mod series_fold; mod series_fold;
+17 -4
View File
@@ -139,11 +139,14 @@ impl PrimitiveBuilder {
self self
} }
/// The resolved node name: the explicit instance name if set, else the /// The resolved node name: the explicit instance name if set, else the
/// type label ASCII-lowercased verbatim (e.g. "SimBroker" -> "simbroker"). /// type label with any `::`-namespace prefix stripped, then ASCII-
/// lowercased (e.g. "SimBroker" -> "simbroker", "demo::Identity" ->
/// "identity").
pub fn node_name(&self) -> String { pub fn node_name(&self) -> String {
self.instance_name self.instance_name.clone().unwrap_or_else(|| {
.clone() let bare = self.name.rsplit("::").next().unwrap_or(self.name);
.unwrap_or_else(|| self.name.to_ascii_lowercase()) bare.to_ascii_lowercase()
})
} }
/// The explicit instance name if one was set via `named()`, else `None`. /// The explicit instance name if one was set via `named()`, else `None`.
/// Unlike `node_name()`, this does not resolve the default — callers that must /// Unlike `node_name()`, this does not resolve the default — callers that must
@@ -430,6 +433,16 @@ mod tests {
assert_eq!(named.node_name(), "fast"); assert_eq!(named.node_name(), "fast");
} }
#[test]
fn node_name_strips_namespace_prefix() {
let b = PrimitiveBuilder::new(
"demo::Identity",
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
|_| unreachable!("never built in this test"),
);
assert_eq!(b.node_name(), "identity");
}
#[test] #[test]
fn instance_name_is_some_only_when_explicitly_named() { fn instance_name_is_some_only_when_explicitly_named() {
// unnamed → None (instance_name() does NOT resolve node_name()'s // unnamed → None (instance_name() does NOT resolve node_name()'s
+159
View File
@@ -0,0 +1,159 @@
//! The project-cdylib descriptor contract (C13/C16, cycle 0102).
//!
//! A research project compiles to a cdylib exporting ONE symbol,
//! `AURA_PROJECT`, a [`ProjectDescriptor`]. The descriptor has two ABI tiers:
//! a **C tier** (`magic`, `descriptor_version`, the version stamps, the
//! namespace — all C-compatible field types) the host validates BEFORE
//! trusting anything, and a **Rust tier** (the vocabulary resolver and the
//! enumerable type-id list) the host touches only after both stamps match.
//! The stamp cannot certify the channel it rides on, so it must not itself
//! depend on the Rust ABI it certifies.
//!
//! The stamps are baked at *aura-core compile time* — per consuming build —
//! so host and dylib each carry their own side's truth.
use crate::node::PrimitiveBuilder;
/// First 8 bytes of the C tier; ASCII "AURAPROJ".
pub const AURA_DESCRIPTOR_MAGIC: u64 = 0x4155_5241_5052_4F4A;
/// Bumped on any layout change of [`ProjectDescriptor`].
pub const AURA_DESCRIPTOR_VERSION: u32 = 1;
/// The one exported symbol name (NUL-terminated for the symbol lookup).
pub const AURA_PROJECT_SYMBOL: &[u8] = b"AURA_PROJECT\0";
/// The compiling rustc's `--version` line (via build.rs).
pub const RUSTC_VERSION: &str = env!("AURA_RUSTC_VERSION");
/// This aura-core's crate version.
pub const CORE_VERSION: &str = env!("CARGO_PKG_VERSION");
/// C-ABI string slice: pointer + length over `'static` UTF-8 bytes.
/// No NUL convention needed; readable without trusting the Rust ABI.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct StrSlice {
pub ptr: *const u8,
pub len: usize,
}
impl StrSlice {
pub const fn new(s: &'static str) -> Self {
Self { ptr: s.as_ptr(), len: s.len() }
}
/// Read the slice back as UTF-8.
///
/// # Safety
/// `ptr`/`len` must describe live UTF-8 bytes — true for any descriptor
/// built by [`aura_project!`](crate::aura_project), whose strings are
/// `'static` and whose library is loaded-and-held (never unloaded).
pub unsafe fn as_str(&self) -> Option<&str> {
if self.ptr.is_null() {
return None;
}
let bytes = unsafe { core::slice::from_raw_parts(self.ptr, self.len) };
core::str::from_utf8(bytes).ok()
}
}
/// The exported project descriptor. Field ORDER is ABI: C tier first,
/// Rust tier last; validated front-to-back.
#[repr(C)]
pub struct ProjectDescriptor {
// ---- C tier: validated BEFORE any Rust-ABI field is touched ----
pub magic: u64,
pub descriptor_version: u32,
pub rustc_version: StrSlice,
pub aura_core_version: StrSlice,
pub namespace: StrSlice,
// ---- Rust tier: only after both stamps match ----
pub vocabulary: fn(&str) -> Option<PrimitiveBuilder>,
pub type_ids: fn() -> &'static [&'static str],
}
// The raw pointers are to 'static data; the fn pointers are Sync by nature.
unsafe impl Sync for ProjectDescriptor {}
/// Declare a project crate's export in one place. Emits the `AURA_PROJECT`
/// static with this build's stamps baked in.
#[macro_export]
macro_rules! aura_project {
(namespace: $ns:literal, vocabulary: $vocab:path, type_ids: $ids:path $(,)?) => {
#[unsafe(no_mangle)]
pub static AURA_PROJECT: $crate::project::ProjectDescriptor =
$crate::project::ProjectDescriptor {
magic: $crate::project::AURA_DESCRIPTOR_MAGIC,
descriptor_version: $crate::project::AURA_DESCRIPTOR_VERSION,
rustc_version: $crate::project::StrSlice::new(
$crate::project::RUSTC_VERSION,
),
aura_core_version: $crate::project::StrSlice::new(
$crate::project::CORE_VERSION,
),
namespace: $crate::project::StrSlice::new($ns),
vocabulary: $vocab,
type_ids: $ids,
};
};
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_vocab(type_id: &str) -> Option<PrimitiveBuilder> {
let _ = type_id;
None
}
fn fake_ids() -> &'static [&'static str] {
&["t::A"]
}
aura_project! {
namespace: "t",
vocabulary: fake_vocab,
type_ids: fake_ids,
}
#[test]
fn stamps_are_baked_and_nonempty() {
assert!(RUSTC_VERSION.starts_with("rustc "));
assert!(!CORE_VERSION.is_empty());
}
#[test]
fn str_slice_round_trips() {
let s = StrSlice::new("hello");
assert_eq!(unsafe { s.as_str() }, Some("hello"));
}
/// `as_str` refuses a null pointer instead of dereferencing it — the
/// loader's boundary-safety primitive for a descriptor field it hasn't
/// otherwise validated yet.
#[test]
fn str_slice_null_ptr_is_none() {
let s = StrSlice { ptr: std::ptr::null(), len: 0 };
assert_eq!(unsafe { s.as_str() }, None);
}
/// `as_str` refuses non-UTF-8 bytes rather than producing a mangled or
/// panicking read, so a corrupt/foreign descriptor field degrades to
/// `None` instead of undefined behaviour downstream.
#[test]
fn str_slice_invalid_utf8_is_none() {
static BAD: [u8; 2] = [0xFF, 0xFE];
let s = StrSlice { ptr: BAD.as_ptr(), len: BAD.len() };
assert_eq!(unsafe { s.as_str() }, None);
}
#[test]
fn macro_emits_a_valid_descriptor() {
let d = &AURA_PROJECT;
assert_eq!(d.magic, AURA_DESCRIPTOR_MAGIC);
assert_eq!(d.descriptor_version, AURA_DESCRIPTOR_VERSION);
assert_eq!(unsafe { d.namespace.as_str() }, Some("t"));
assert_eq!(unsafe { d.rustc_version.as_str() }, Some(RUSTC_VERSION));
assert_eq!(unsafe { d.aura_core_version.as_str() }, Some(CORE_VERSION));
assert!((d.vocabulary)("nope").is_none());
assert_eq!((d.type_ids)(), &["t::A"]);
}
}
+1
View File
@@ -1003,6 +1003,7 @@ mod tests {
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics: summarize(&equity, &exposure), metrics: summarize(&equity, &exposure),
} }
+2 -1
View File
@@ -71,7 +71,8 @@ pub use harness::{
pub use report::{ pub use report::{
derive_position_events, expected_max_of_normals, f64_field, inv_norm_cdf, join_on_ts, 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, 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::{ pub use sweep::{
sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint, sweep, GridSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily, SweepPoint,
+2
View File
@@ -245,6 +245,7 @@ mod tests {
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics: summarize(&equity, &exposure), metrics: summarize(&equity, &exposure),
} }
@@ -271,6 +272,7 @@ mod tests {
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics: RunMetrics { metrics: RunMetrics {
total_pips: v, total_pips: v,
+46
View File
@@ -57,6 +57,23 @@ pub struct RunManifest {
/// serde widening (Tier-1, #156), identical idiom to `selection` / `instrument`. /// serde widening (Tier-1, #156), identical idiom to `selection` / `instrument`.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub topology_hash: Option<String>, 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 /// 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)), commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
seed: 0, broker: "b".into(), selection: None, instrument: None, seed: 0, broker: "b".into(), selection: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}; };
assert!(!serde_json::to_string(&m).unwrap().contains("selection")); 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)), commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
seed: 0, broker: "b".into(), selection: None, instrument: Some("GER40".into()), seed: 0, broker: "b".into(), selection: None, instrument: Some("GER40".into()),
topology_hash: None, topology_hash: None,
project: None,
}; };
let json = serde_json::to_string(&with).unwrap(); let json = serde_json::to_string(&with).unwrap();
assert!(json.contains("\"instrument\":\"GER40\""), "json: {json}"); assert!(json.contains("\"instrument\":\"GER40\""), "json: {json}");
@@ -308,11 +327,33 @@ mod tests {
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)), commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
seed: 0, broker: "b".into(), selection: None, instrument: None, seed: 0, broker: "b".into(), selection: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}; };
// skip_serializing_if omits the key when None -> existing manifest bytes unchanged (C23). // skip_serializing_if omits the key when None -> existing manifest bytes unchanged (C23).
assert!(!serde_json::to_string(&without).unwrap().contains("instrument")); 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] #[test]
fn family_selection_round_trips_on_the_manifest() { fn family_selection_round_trips_on_the_manifest() {
let sel = FamilySelection { let sel = FamilySelection {
@@ -326,6 +367,7 @@ mod tests {
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)), commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
seed: 0, broker: "b".into(), selection: Some(sel.clone()), instrument: None, seed: 0, broker: "b".into(), selection: Some(sel.clone()), instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}; };
let back: RunManifest = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap(); let back: RunManifest = serde_json::from_str(&serde_json::to_string(&m).unwrap()).unwrap();
assert_eq!(back.selection, Some(sel)); assert_eq!(back.selection, Some(sel));
@@ -466,6 +508,7 @@ mod tests {
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics, metrics,
} }
@@ -571,6 +614,7 @@ mod tests {
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics: RunMetrics { metrics: RunMetrics {
total_pips: 12.0, total_pips: 12.0,
@@ -602,6 +646,7 @@ mod tests {
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, bias_sign_flips: 1, r: 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, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, bias_sign_flips: 1, r: 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, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics: summarize(&equity, &exposure), metrics: summarize(&equity, &exposure),
} }
+1
View File
@@ -294,6 +294,7 @@ mod tests {
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics: summarize(&[], &[]), metrics: summarize(&[], &[]),
} }
@@ -118,6 +118,7 @@ fn run_point(point: &[Cell]) -> RunReport {
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics: summarize(&equity, &exposure), metrics: summarize(&equity, &exposure),
} }
@@ -69,6 +69,7 @@ fn run_point(server: &Arc<DataServer>, point: &[Cell], from: Timestamp, to: Time
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics: summarize(&equity, &exposure), metrics: summarize(&equity, &exposure),
} }
@@ -79,6 +79,7 @@ fn run_point(
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics: summarize(&equity, &exposure), metrics: summarize(&equity, &exposure),
}; };
@@ -402,6 +402,7 @@ pub fn report_from_trace(trace: &[BarTrace], from: Timestamp, to: Timestamp) ->
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics, metrics,
} }
@@ -71,6 +71,7 @@ fn run_point(server: &Arc<DataServer>, point: &[Cell], from: Timestamp, to: Time
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics: summarize(&equity, &exposure), metrics: summarize(&equity, &exposure),
} }
+1
View File
@@ -92,6 +92,7 @@ fn run_sample_over(prices: Vec<(Timestamp, Scalar)>) -> RunReport {
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics, metrics,
} }
@@ -92,6 +92,7 @@ fn run_sample(window: (Timestamp, Timestamp), source: Box<dyn Source>) -> RunRep
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics: summarize(&equity, &exposure), metrics: summarize(&equity, &exposure),
} }
+9 -3
View File
@@ -10,7 +10,9 @@
//! (`Registry::append`, `RunReport::to_json`) is untouched and still emits the //! (`Registry::append`, `RunReport::to_json`) is untouched and still emits the
//! tagged form, so this is a one-directional widening, not a format change. //! tagged form, so this is a one-directional widening, not a format change.
use aura_engine::{FamilySelection, RunManifest, RunMetrics, RunReport, Scalar, Timestamp}; use aura_engine::{
FamilySelection, ProjectProvenance, RunManifest, RunMetrics, RunReport, Scalar, Timestamp,
};
use serde::de::{self, Deserializer}; use serde::de::{self, Deserializer};
use serde::Deserialize; use serde::Deserialize;
@@ -36,6 +38,8 @@ struct RunManifestRead {
instrument: Option<String>, instrument: Option<String>,
#[serde(default)] #[serde(default)]
topology_hash: Option<String>, topology_hash: Option<String>,
#[serde(default)]
project: Option<ProjectProvenance>,
} }
/// A param value that accepts BOTH the current tagged [`Scalar`] form /// A param value that accepts BOTH the current tagged [`Scalar`] form
@@ -68,8 +72,9 @@ impl<'de> Deserialize<'de> for ScalarRead {
impl From<RunReportRead> for RunReport { impl From<RunReportRead> for RunReport {
fn from(r: RunReportRead) -> Self { fn from(r: RunReportRead) -> Self {
let RunManifestRead { commit, params, window, seed, broker, selection, instrument, topology_hash } = let RunManifestRead {
r.manifest; commit, params, window, seed, broker, selection, instrument, topology_hash, project,
} = r.manifest;
RunReport { RunReport {
manifest: RunManifest { manifest: RunManifest {
commit, commit,
@@ -80,6 +85,7 @@ impl From<RunReportRead> for RunReport {
selection, selection,
instrument, instrument,
topology_hash, topology_hash,
project,
}, },
metrics: r.metrics, metrics: r.metrics,
} }
+2
View File
@@ -589,6 +589,7 @@ mod tests {
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics: RunMetrics { total_pips, max_drawdown, bias_sign_flips: flips, r: None }, metrics: RunMetrics { total_pips, max_drawdown, bias_sign_flips: flips, r: None },
} }
@@ -936,6 +937,7 @@ mod tests {
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)), commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
seed: 0, broker: "b".into(), selection: None, instrument: None, seed: 0, broker: "b".into(), selection: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
}, },
metrics: RunMetrics { metrics: RunMetrics {
total_pips, max_drawdown: 0.0, bias_sign_flips: 0, total_pips, max_drawdown: 0.0, bias_sign_flips: 0,
+1
View File
@@ -256,6 +256,7 @@ mod tests {
selection: None, selection: None,
instrument: None, instrument: None,
topology_hash: None, topology_hash: None,
project: None,
} }
} }
+27 -9
View File
@@ -1053,6 +1053,24 @@ toolchain plugins.
**Why.** Hot-reload makes the research loop fast; a live artifact must be frozen **Why.** Hot-reload makes the research loop fast; a live artifact must be frozen
and reproducible (audit trail: this bot = this commit). A sweep pays no and reproducible (audit trail: this bot = this commit). A sweep pays no
hot-reload tax — params are runtime data (C12), so the cdylib loads once. hot-reload tax — params are runtime data (C12), so the cdylib loads once.
**Realization (cycle 0102 — the load boundary; per-invocation reload).** The
authoring-loop half is realized: a project is an external cdylib crate loaded
per invocation through a two-tier `#[repr(C)]` descriptor (`AURA_PROJECT`,
`aura-core::project`) — a C-ABI stamp prefix (rustc version + aura-core
version, baked per consuming build) validated **before** any Rust-ABI field is
touched, then the vocabulary resolver + enumerable type-id list behind the
stamp gate. "Hot-reload" reads, in v1, as **per-invocation load of the
freshest build**: the author (Claude) runs `cargo build`, the next `aura`
invocation locates the artifact via `cargo metadata` (debug default,
`--release` opt-in) and loads it **load-and-hold** (leaked, never unloaded).
Scope boundary: load-and-hold is trivially sound only because the CLI is a
one-shot process — a future long-running host (the open local-server thread,
C22) must re-solve reload (host restart or subprocess isolation), never
in-process unload. Mismatch of either stamp refuses (exit 1) naming both
sides; the project vocabulary is charter-checked at load (`::`-namespaced ids,
no duplicates against std, list↔resolver cross-check) — the invariant-9
data-plane discipline of the C24 enforcement-shift note, now enforced at the
one seam.
### C14 — Headless core, two faces ### C14 — Headless core, two faces
**Guarantee.** The engine is a UI-agnostic library. Two faces sit on it: a **Guarantee.** The engine is a UI-agnostic library. Two faces sit on it: a
@@ -1145,8 +1163,8 @@ their own repos, pulled as cargo git deps) / **project-local `nodes/`**
(experimental, project-specific). A reusable node is an `rlib` dependency; the (experimental, project-specific). A reusable node is an `rlib` dependency; the
hot-reload unit stays the project-side `cdylib` that composes it (consistent hot-reload unit stays the project-side `cdylib` that composes it (consistent
with C13). Concretely a project is a **Rust crate** — a cdylib library of node / with C13). Concretely a project is a **Rust crate** — a cdylib library of node /
strategy / experiment blueprints — plus a static `Aura.toml` (project context: strategy / experiment blueprints — plus a static `Aura.toml` (project context,
data paths, instrument/pip metadata, default broker & window, runs dir). During paths only: data archive root, runs dir — cycle 0102). During
research the `aura` host loads and runs it (C13 hot-reload); for deploy the research the `aura` host loads and runs it (C13 hot-reload); for deploy the
chosen strategy + broker freeze into a standalone binary. **A project is chosen strategy + broker freeze into a standalone binary. **A project is
therefore always a Rust program built on the engine.** Beyond the node-reuse therefore always a Rust program built on the engine.** Beyond the node-reuse
@@ -1207,7 +1225,7 @@ into a reusable domain-agnostic substrate — until then `RunReport` / `RunManif
is authored in native Rust through **Claude Code + the skills pipeline**: the is authored in native Rust through **Claude Code + the skills pipeline**: the
human describes, Claude writes the Rust, builds it, runs it via the `aura` CLI, human describes, Claude writes the Rust, builds it, runs it via the `aura` CLI,
and reports metrics. Declarative config (`Aura.toml`) carries only **static and reports metrics. Declarative config (`Aura.toml`) carries only **static
project context** (data paths, instrument/pip metadata, defaults, runs dir), project context** (paths only: data archive root, runs dir — cycle 0102),
never logic. aura ships **no embedded coding-LLM**. IONOS LLMs are used only as a never logic. aura ships **no embedded coding-LLM**. IONOS LLMs are used only as a
*runtime data source* (news-agent bias, C11), gated by per-session consent, *runtime data source* (news-agent bias, C11), gated by per-session consent,
never in the code path. never in the code path.
@@ -2055,12 +2073,12 @@ artifact, and any run is deterministic once instantiated" (C1).
atop the atomic sim unit (C12), not yet designed. Search over *params* sits atop the atop the atomic sim unit (C12), not yet designed. Search over *params* sits atop the
atomic unit; search over *structure* (graph mutation / crossover — NEAT-style) atomic unit; search over *structure* (graph mutation / crossover — NEAT-style)
additionally needs topology-as-data (C24). additionally needs topology-as-data (C24).
- **`aura new` scaffolder, the experiment-builder API, and `Aura.toml`'s - **`aura new` scaffolder and the experiment-builder API** — `aura new`
static-context schema** — `aura new` scaffolds a Rust project *crate* (node / scaffolds a Rust project *crate* (node / strategy blueprints) against the
strategy / experiment blueprints) against the engine (C16/C20); the engine (C16/C20); the experiment-builder API surface (harness wiring,
experiment-builder API surface (harness wiring, structural axes, sweep structural axes, sweep combinators) is not yet designed. `Aura.toml`'s
combinators) and `Aura.toml`'s schema (data paths, instrument/pip metadata, schema was settled paths-only in cycle 0102 (data archive root, runs dir —
default broker & window, runs dir) are not yet designed. see the C13 realization note); the load boundary itself shipped there.
- **The analysis meta-level — composable orchestration + project-as-crate authoring - **The analysis meta-level — composable orchestration + project-as-crate authoring
(tracked: #109).** aura's differentiator (C21) has two unbuilt halves: (1) the (tracked: #109).** aura's differentiator (C21) has two unbuilt halves: (1) the
orchestration axes (sweep / Monte-Carlo / walk-forward) becoming *composable tools* orchestration axes (sweep / Monte-Carlo / walk-forward) becoming *composable tools*
+1 -1
View File
@@ -17,7 +17,7 @@ The primitive `(frozen topology + param-set + data-window + RNG-seed) → determ
### Aura.toml ### Aura.toml
**Avoid:** — **Avoid:** —
The per-project declarative config holding only static context (data paths, instrument/pip metadata, default broker & window, runs dir), never logic. Its presence marks the project root, the way `Cargo.toml` marks a cargo crate. The per-project declarative config holding only static context paths only: the data archive root and the runs dir never logic, and never instrument geometry (that is the recorded sidecar, C15). Its presence marks the project root, the way `Cargo.toml` marks a cargo crate.
### backtest ### backtest
**Avoid:** — **Avoid:** —
+3 -2
View File
@@ -30,8 +30,9 @@ A project is always a Rust program built on the engine.
``` ```
ger40-lab/ # your research project — a separate Rust crate (cdylib) ger40-lab/ # your research project — a separate Rust crate (cdylib)
├── Aura.toml # STATIC context only: data paths, instrument/pip ├── Aura.toml # STATIC context only, paths only: data archive root,
│ # metadata, default broker & window, runs dir (no logic) │ # runs dir (no logic; instrument geometry is the
│ # recorded sidecar, C15 — never authored here)
├── Cargo.toml # cdylib; depends on aura-core/aura-std (+ shared node crates); ├── Cargo.toml # cdylib; depends on aura-core/aura-std (+ shared node crates);
│ # carries an empty [workspace] table (see note below) │ # carries an empty [workspace] table (see note below)
├── CLAUDE.md # the project's own skills wiring (authoring discipline) ├── CLAUDE.md # the project's own skills wiring (authoring discipline)