refactor: extract the member-run recipe into library crates (#295 part 1)
The shell no longer defines what an aura backtest is. Tasks 1-9 of the shell-boundary cycle — structural extraction, behaviour byte-identical: - aura-runner (new; the C28 assembly position): input binding (the C26 module, moved whole), the C1-load-bearing param<->config translators, harness assembly (wrap_r / run_signal_r / run_blueprint_member and the probe/reopen cluster), axis + risk-regime conventions (bind_axes et al.), the campaign family builders + MC guards, reproduce (process::exit -> RunnerError, shell remaps to identical bytes), the measurement-run orchestration, project loading (Env / cdylib load / charter / staleness, moved whole), the coverage gap-walk (deduplicated onto interior_gap_months), and DefaultMemberRunner — the public implementation of aura_campaign::MemberRunner (ex CliMemberRunner). The MemberRunner trait stays in aura-campaign; the column still imports no harness/data-binding machinery. - aura-measurement (new; seeds C28 rung 3): the IcMetrics/IcKey vocabulary + information_coefficient, verbatim incl. serde derives (#294 duplicate-timestamp semantics move as-is, unresolved). - aura-backtest: the pure per-run scaffold (point_from_params, wf_ms_sizes / fit_wf_ms_sizes, intersect_shared_window). - shell residue: main.rs / campaign_run.rs keep argv/dispatch, argv->document translation, and presentation only; walkforward_summary_json_from_reports now calls the public aura_engine::param_stability instead of its inline twin. - worked example (crates/aura-runner/examples/world_member_run.rs): a World program runs a member backtest through the library alone. Held quality findings, adjudicated: the plan's literal pub-visibility list for binding.rs kept (cosmetic); ~20 refusal sites inside aura-runner (family/member/measure/translate) still process::exit — behaviour-identical today, conversion to RunnerError is filed forward (refs #297); rustfmt line-width drift on re-pathed call sites left for a repo-wide fmt decision. Verification: cargo test --workspace green (1471 passed, 0 failed); cargo clippy --workspace --all-targets -D warnings clean. Byte-identity is pinned by the untouched shell E2E suites (cli_run, measure_ic, run_measurement, research_docs, run_refuses_unrunnable_blueprint, tap_recording — zero assertion edits). Remaining in this cycle: full-workspace c28_layering + shell-content check, ledger amendments (C28 assembly position, C25/C14 control-surface consequence, C26 realization note). refs #295
This commit is contained in:
@@ -1,433 +0,0 @@
|
||||
//! Harness input binding — the one place strategy input-role NAMES meet archive
|
||||
//! COLUMNS (docs/specs/harness-input-binding.md, #231). A root input role whose
|
||||
//! name is in the closed column vocabulary (`open`/`high`/`low`/`close`/
|
||||
//! `spread`/`volume`, plus `price` as the backward-compatible alias of `close`)
|
||||
//! binds to that column of the run's instrument by default; a campaign
|
||||
//! document's `data.bindings` block (role -> column) overrides the name default
|
||||
//! per campaign. The resolved plan (`ResolvedBinding`) is consumed twice — to
|
||||
//! OPEN the columns (`aura_ingest::open_columns`) and to DECLARE the wrapped
|
||||
//! root roles (`wrap_r`) — in the same canonical `M1Field` declaration order,
|
||||
//! so the two halves of the old single-price weld can no longer drift apart
|
||||
//! (C4 merge tie-break = role declaration order = source open order).
|
||||
//!
|
||||
//! `dead_code` is allowed module-wide only while nothing outside this
|
||||
//! module consumes it; the allow is scaffolding to REMOVE with the first
|
||||
//! consumer (the wrap seam), at which point genuinely unused items must
|
||||
//! surface again.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aura_core::ScalarKind;
|
||||
use aura_engine::Role;
|
||||
use aura_ingest::M1Field;
|
||||
|
||||
/// The reserved name of the synthesized broker/executor close entry —
|
||||
/// `finish` creates it under this name and `resolve_binding`'s collision
|
||||
/// guard defends it; one constant couples the two sites.
|
||||
const RESERVED_CLOSE_ROLE: &str = "close";
|
||||
|
||||
/// The vocabulary line every binding refusal names (single source for prose).
|
||||
pub(crate) const COLUMN_VOCABULARY: &str =
|
||||
"open, high, low, close (alias: price), spread, volume";
|
||||
|
||||
/// The `&'static str` form of a vocabulary role name, when it is one — the
|
||||
/// common case at the wrap seam, so declaring a port for a vocabulary-named
|
||||
/// role needs no per-build leak; only an override-renamed (non-vocabulary)
|
||||
/// role falls back to leaking its name.
|
||||
pub(crate) fn static_role_name(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"open" => Some("open"),
|
||||
"high" => Some("high"),
|
||||
"low" => Some("low"),
|
||||
"close" => Some("close"),
|
||||
"price" => Some("price"),
|
||||
"spread" => Some("spread"),
|
||||
"volume" => Some("volume"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The closed role-name -> archive-column map (`price` aliases `close`).
|
||||
pub(crate) fn column_for_role(name: &str) -> Option<M1Field> {
|
||||
match name {
|
||||
"open" => Some(M1Field::Open),
|
||||
"high" => Some(M1Field::High),
|
||||
"low" => Some(M1Field::Low),
|
||||
"close" | "price" => Some(M1Field::Close),
|
||||
"spread" => Some(M1Field::Spread),
|
||||
"volume" => Some(M1Field::Volume),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical column name (refusal prose; the alias-free inverse of
|
||||
/// [`column_for_role`]).
|
||||
pub(crate) fn column_name(column: M1Field) -> &'static str {
|
||||
match column {
|
||||
M1Field::Open => "open",
|
||||
M1Field::High => "high",
|
||||
M1Field::Low => "low",
|
||||
M1Field::Close => "close",
|
||||
M1Field::Spread => "spread",
|
||||
M1Field::Volume => "volume",
|
||||
}
|
||||
}
|
||||
|
||||
/// The scalar kind a column streams (mirrors `aura_ingest::decode`: Volume is
|
||||
/// the one `i64` column; everything else is `f64`).
|
||||
pub(crate) fn column_kind(column: M1Field) -> ScalarKind {
|
||||
match column {
|
||||
M1Field::Volume => ScalarKind::I64,
|
||||
_ => ScalarKind::F64,
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical rank = `M1Field` declaration order — the C4 merge tie-break
|
||||
/// order `open_ohlc` (#92) fixed and `open_columns` opens in.
|
||||
fn rank(column: M1Field) -> usize {
|
||||
match column {
|
||||
M1Field::Open => 0,
|
||||
M1Field::High => 1,
|
||||
M1Field::Low => 2,
|
||||
M1Field::Close => 3,
|
||||
M1Field::Spread => 4,
|
||||
M1Field::Volume => 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// One resolved root role: the role name to declare (= the signal's port
|
||||
/// name), the archive column it binds, and whether it feeds the signal
|
||||
/// (`false` only for the synthesized broker/executor-only close entry).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct BindingEntry {
|
||||
pub role: String,
|
||||
pub column: M1Field,
|
||||
pub feeds_signal: bool,
|
||||
}
|
||||
|
||||
/// The ordered binding plan `wrap_r` (role declaration) and the real-data
|
||||
/// openers (column opening) share: entries in canonical column order, with a
|
||||
/// close entry guaranteed (the broker/executor price feed).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ResolvedBinding {
|
||||
entries: Vec<BindingEntry>,
|
||||
}
|
||||
|
||||
impl ResolvedBinding {
|
||||
pub(crate) fn entries(&self) -> &[BindingEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// The columns to open, one per entry, in canonical order — exactly the
|
||||
/// order the entries' roles are declared in, so source index i always
|
||||
/// feeds role i.
|
||||
pub(crate) fn columns(&self) -> Vec<M1Field> {
|
||||
self.entries.iter().map(|e| e.column).collect()
|
||||
}
|
||||
|
||||
/// True iff the strategy consumes the close column only — the one shape
|
||||
/// the synthetic walk (a single close series) can drive.
|
||||
pub(crate) fn close_only(&self) -> bool {
|
||||
self.columns() == [M1Field::Close]
|
||||
}
|
||||
}
|
||||
|
||||
/// Guarantee a close entry (the broker/executor pair always feeds from
|
||||
/// close), synthesizing a signal-blind one when the strategy consumes none,
|
||||
/// then sort into canonical column order (stable: same-column entries keep
|
||||
/// their declared order). Shared tail of strict and probe resolution.
|
||||
fn finish(mut entries: Vec<BindingEntry>) -> ResolvedBinding {
|
||||
if !entries.iter().any(|e| e.column == M1Field::Close) {
|
||||
entries.push(BindingEntry {
|
||||
role: RESERVED_CLOSE_ROLE.to_string(),
|
||||
column: M1Field::Close,
|
||||
feeds_signal: false,
|
||||
});
|
||||
}
|
||||
entries.sort_by_key(|e| rank(e.column));
|
||||
ResolvedBinding { entries }
|
||||
}
|
||||
|
||||
/// Resolve a strategy's input roles against the campaign overrides: an
|
||||
/// override wins over the name default; a role that is neither
|
||||
/// vocabulary-named nor overridden is a refusal naming the vocabulary and the
|
||||
/// override remedy.
|
||||
pub(crate) fn resolve_binding(
|
||||
strategy: &str,
|
||||
roles: &[Role],
|
||||
overrides: &BTreeMap<String, String>,
|
||||
) -> Result<ResolvedBinding, String> {
|
||||
// `price` is the alias of `close`: both present is ambiguous (which one
|
||||
// is the broker feed?) — refuse rather than guess.
|
||||
if roles.iter().any(|r| r.name == "price") && roles.iter().any(|r| r.name == "close") {
|
||||
return Err(format!(
|
||||
"strategy \"{strategy}\" declares both \"price\" and \"close\" input roles — \
|
||||
\"price\" is the alias of \"close\", so the pair is ambiguous; rename one role"
|
||||
));
|
||||
}
|
||||
// Duplicate role names would lower to duplicate keyed feeds, which the by-name
|
||||
// bind (`run_bound`) rejects as `DuplicateFeed`. The blueprint path does not
|
||||
// enforce role-name uniqueness (only the construction op-script path does), so
|
||||
// refuse here — a named refusal on every CLI run path, not a panic behind the
|
||||
// `.expect()` the single-binding invariant relies on.
|
||||
if let Some(dup) = first_duplicate_role(roles) {
|
||||
return Err(format!(
|
||||
"strategy \"{strategy}\" declares input role \"{dup}\" more than once — \
|
||||
input role names must be unique"
|
||||
));
|
||||
}
|
||||
let mut entries: Vec<BindingEntry> = Vec::with_capacity(roles.len() + 1);
|
||||
for role in roles {
|
||||
let column = match overrides.get(&role.name) {
|
||||
Some(value) => column_for_role(value).ok_or_else(|| {
|
||||
format!(
|
||||
"binding for role \"{}\" of strategy \"{strategy}\" names no archive \
|
||||
column: \"{value}\" — bindable columns: {COLUMN_VOCABULARY}",
|
||||
role.name
|
||||
)
|
||||
})?,
|
||||
None => column_for_role(&role.name).ok_or_else(|| {
|
||||
format!(
|
||||
"input role \"{}\" of strategy \"{strategy}\" binds to no archive column \
|
||||
— bindable columns: {COLUMN_VOCABULARY}; or bind it explicitly in the \
|
||||
campaign data.bindings block",
|
||||
role.name
|
||||
)
|
||||
})?,
|
||||
};
|
||||
// A role literally named "close" bound away from the close column
|
||||
// would collide with `finish`'s synthesized close entry (two
|
||||
// entries named "close") — refuse rather than double-name.
|
||||
if role.name == RESERVED_CLOSE_ROLE && column != M1Field::Close {
|
||||
return Err(format!(
|
||||
"binding override renames role \"close\" to column \"{}\" — \"close\" is the \
|
||||
reserved name of the synthesized broker/executor entry; rename the role",
|
||||
column_name(column)
|
||||
));
|
||||
}
|
||||
entries.push(BindingEntry { role: role.name.clone(), column, feeds_signal: true });
|
||||
}
|
||||
Ok(finish(entries))
|
||||
}
|
||||
|
||||
/// The first role name that appears more than once in `roles`, in declaration
|
||||
/// order, or `None` if every name is unique.
|
||||
fn first_duplicate_role(roles: &[Role]) -> Option<&str> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for r in roles {
|
||||
if !seen.insert(r.name.as_str()) {
|
||||
return Some(r.name.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The lenient PROBE binding for the throwaway param-space wrap
|
||||
/// (`blueprint_axis_probe`): every unresolvable role falls back to the close
|
||||
/// column. The probe composite is built but never run over data — the
|
||||
/// fallback is a placeholder exactly like the probe's synthetic pip, so
|
||||
/// probing never refuses a blueprint the (strictly resolved) run path may
|
||||
/// still bind via campaign overrides.
|
||||
pub(crate) fn probe_binding(roles: &[Role]) -> ResolvedBinding {
|
||||
let entries: Vec<BindingEntry> = roles
|
||||
.iter()
|
||||
.map(|role| BindingEntry {
|
||||
role: role.name.clone(),
|
||||
column: column_for_role(&role.name).unwrap_or(M1Field::Close),
|
||||
feeds_signal: true,
|
||||
})
|
||||
.collect();
|
||||
finish(entries)
|
||||
}
|
||||
|
||||
/// The refusal for a multi-column binding on synthetic data (which generates
|
||||
/// a close series only). Callers invoke it only when `!binding.close_only()`.
|
||||
pub(crate) fn synthetic_refusal(strategy: &str, binding: &ResolvedBinding) -> String {
|
||||
let beyond: Vec<&str> = binding
|
||||
.entries()
|
||||
.iter()
|
||||
.filter(|e| e.column != M1Field::Close)
|
||||
.map(|e| column_name(e.column))
|
||||
.collect();
|
||||
format!(
|
||||
"strategy \"{strategy}\" consumes columns beyond close ({}) — synthetic data \
|
||||
generates a close series only; run with --real <SYMBOL>",
|
||||
beyond.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn role(name: &str) -> Role {
|
||||
Role { name: name.to_string(), targets: vec![], source: Some(ScalarKind::F64) }
|
||||
}
|
||||
|
||||
/// The closed name map: six columns plus the `price` alias of close.
|
||||
#[test]
|
||||
fn column_for_role_maps_the_vocabulary_and_the_price_alias() {
|
||||
assert_eq!(column_for_role("open"), Some(M1Field::Open));
|
||||
assert_eq!(column_for_role("high"), Some(M1Field::High));
|
||||
assert_eq!(column_for_role("low"), Some(M1Field::Low));
|
||||
assert_eq!(column_for_role("close"), Some(M1Field::Close));
|
||||
assert_eq!(column_for_role("price"), Some(M1Field::Close));
|
||||
assert_eq!(column_for_role("spread"), Some(M1Field::Spread));
|
||||
assert_eq!(column_for_role("volume"), Some(M1Field::Volume));
|
||||
assert_eq!(column_for_role("sentiment"), None);
|
||||
}
|
||||
|
||||
/// The doc-tier value vocabulary (aura-research) and this module's
|
||||
/// role->column map agree exactly — the #160-style two-surface pin, so
|
||||
/// the campaign-validate tier and the bind tier cannot drift.
|
||||
#[test]
|
||||
fn research_vocabulary_agrees_with_column_for_role() {
|
||||
let vocab = aura_research::binding_column_vocabulary();
|
||||
assert_eq!(vocab.len(), 7, "six columns + the price alias");
|
||||
for token in vocab {
|
||||
assert!(column_for_role(token).is_some(), "doc-tier token {token} must resolve");
|
||||
}
|
||||
assert!(column_for_role("hl2").is_none(), "non-vocabulary tokens must not resolve");
|
||||
}
|
||||
|
||||
/// Volume is the one i64 column (mirrors `aura_ingest::decode`).
|
||||
#[test]
|
||||
fn column_kind_is_i64_for_volume_and_f64_otherwise() {
|
||||
assert_eq!(column_kind(M1Field::Volume), ScalarKind::I64);
|
||||
assert_eq!(column_kind(M1Field::Close), ScalarKind::F64);
|
||||
assert_eq!(column_kind(M1Field::Open), ScalarKind::F64);
|
||||
}
|
||||
|
||||
/// The compatibility anchor: a single `price` role resolves to exactly
|
||||
/// today's weld — one close-bound entry, no synthesized extra.
|
||||
#[test]
|
||||
fn single_price_role_resolves_to_exactly_todays_weld() {
|
||||
let b = resolve_binding("s", &[role("price")], &BTreeMap::new()).expect("resolves");
|
||||
assert_eq!(b.entries().len(), 1);
|
||||
assert_eq!(b.entries()[0].role, "price");
|
||||
assert_eq!(b.entries()[0].column, M1Field::Close);
|
||||
assert!(b.entries()[0].feeds_signal);
|
||||
assert!(b.close_only());
|
||||
}
|
||||
|
||||
/// An override wins over the name default; rebinding `price` away from
|
||||
/// close synthesizes the broker-only close entry.
|
||||
#[test]
|
||||
fn override_wins_and_synthesizes_the_broker_close_entry() {
|
||||
let overrides = BTreeMap::from([("price".to_string(), "open".to_string())]);
|
||||
let b = resolve_binding("s", &[role("price")], &overrides).expect("resolves");
|
||||
assert_eq!(b.columns(), vec![M1Field::Open, M1Field::Close]);
|
||||
assert_eq!(b.entries()[0].role, "price");
|
||||
assert!(b.entries()[0].feeds_signal);
|
||||
assert_eq!(b.entries()[1].role, "close");
|
||||
assert!(!b.entries()[1].feeds_signal, "synthesized close feeds broker/executor only");
|
||||
assert!(!b.close_only());
|
||||
}
|
||||
|
||||
/// A high/low/close strategy shares its close entry with the broker —
|
||||
/// nothing synthesized.
|
||||
#[test]
|
||||
fn consumed_close_is_shared_not_duplicated() {
|
||||
let roles = [role("high"), role("low"), role("close")];
|
||||
let b = resolve_binding("s", &roles, &BTreeMap::new()).expect("resolves");
|
||||
assert_eq!(b.columns(), vec![M1Field::High, M1Field::Low, M1Field::Close]);
|
||||
assert!(b.entries().iter().all(|e| e.feeds_signal));
|
||||
}
|
||||
|
||||
/// Canonical ordering: declaration order in the blueprint is irrelevant —
|
||||
/// entries sort into `M1Field` declaration order (the C4 tie-break order).
|
||||
#[test]
|
||||
fn entries_sort_into_canonical_column_order() {
|
||||
let roles = [role("volume"), role("close"), role("open")];
|
||||
let b = resolve_binding("s", &roles, &BTreeMap::new()).expect("resolves");
|
||||
assert_eq!(b.columns(), vec![M1Field::Open, M1Field::Close, M1Field::Volume]);
|
||||
}
|
||||
|
||||
/// The unknown-role refusal names the vocabulary AND the override remedy
|
||||
/// (exact prose — the spec's error contract).
|
||||
#[test]
|
||||
fn unknown_role_refuses_naming_vocabulary_and_remedy() {
|
||||
let err = resolve_binding("x", &[role("sentiment")], &BTreeMap::new()).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
"input role \"sentiment\" of strategy \"x\" binds to no archive column — \
|
||||
bindable columns: open, high, low, close (alias: price), spread, volume; \
|
||||
or bind it explicitly in the campaign data.bindings block"
|
||||
);
|
||||
}
|
||||
|
||||
/// An override VALUE outside the vocabulary refuses naming the vocabulary.
|
||||
#[test]
|
||||
fn bad_override_value_refuses_naming_the_vocabulary() {
|
||||
let overrides = BTreeMap::from([("price".to_string(), "hl2".to_string())]);
|
||||
let err = resolve_binding("x", &[role("price")], &overrides).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
"binding for role \"price\" of strategy \"x\" names no archive column: \"hl2\" \
|
||||
— bindable columns: open, high, low, close (alias: price), spread, volume"
|
||||
);
|
||||
}
|
||||
|
||||
/// `price` + `close` both declared is the alias ambiguity — refused.
|
||||
#[test]
|
||||
fn price_and_close_together_refuse_as_ambiguous() {
|
||||
let err =
|
||||
resolve_binding("x", &[role("price"), role("close")], &BTreeMap::new()).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
"strategy \"x\" declares both \"price\" and \"close\" input roles — \"price\" \
|
||||
is the alias of \"close\", so the pair is ambiguous; rename one role"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two input roles with the same name would lower to a duplicate keyed feed;
|
||||
/// `resolve_binding` refuses at the binding boundary (a named refusal, not a
|
||||
/// `run_bound` `DuplicateFeed` panic behind the CLI's `.expect()`).
|
||||
#[test]
|
||||
fn duplicate_role_name_refuses() {
|
||||
let err =
|
||||
resolve_binding("x", &[role("high"), role("high")], &BTreeMap::new()).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
"strategy \"x\" declares input role \"high\" more than once — \
|
||||
input role names must be unique"
|
||||
);
|
||||
}
|
||||
|
||||
/// A role literally named "close" overridden away from the close column
|
||||
/// would otherwise collide with `finish`'s synthesized close entry (two
|
||||
/// entries both named "close") — refused instead of double-naming.
|
||||
#[test]
|
||||
fn role_named_close_overridden_away_refuses_the_reserved_name() {
|
||||
let overrides = BTreeMap::from([("close".to_string(), "open".to_string())]);
|
||||
let err = resolve_binding("x", &[role("close")], &overrides).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
"binding override renames role \"close\" to column \"open\" — \"close\" is the \
|
||||
reserved name of the synthesized broker/executor entry; rename the role"
|
||||
);
|
||||
}
|
||||
|
||||
/// The lenient probe binding never refuses: unresolvable roles fall back
|
||||
/// to close (probe-only, like the probe's synthetic pip).
|
||||
#[test]
|
||||
fn probe_binding_falls_back_to_close_for_unknown_roles() {
|
||||
let b = probe_binding(&[role("sentiment"), role("high")]);
|
||||
assert_eq!(b.columns(), vec![M1Field::High, M1Field::Close]);
|
||||
assert_eq!(b.entries()[1].role, "sentiment");
|
||||
assert!(b.entries()[1].feeds_signal);
|
||||
}
|
||||
|
||||
/// The synthetic refusal prose names the beyond-close columns + remedy.
|
||||
#[test]
|
||||
fn synthetic_refusal_names_columns_and_remedy() {
|
||||
let roles = [role("high"), role("low"), role("close")];
|
||||
let b = resolve_binding("hl_channel", &roles, &BTreeMap::new()).expect("resolves");
|
||||
assert_eq!(
|
||||
synthetic_refusal("hl_channel", &b),
|
||||
"strategy \"hl_channel\" consumes columns beyond close (high, low) — synthetic \
|
||||
data generates a close series only; run with --real <SYMBOL>"
|
||||
);
|
||||
}
|
||||
}
|
||||
+30
-1113
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ use aura_engine::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
// `std_vocabulary` is now only reached through `crate::project::Env::resolve` in
|
||||
// `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in
|
||||
// production code; tests still exercise it directly.
|
||||
#[cfg(test)]
|
||||
use aura_vocabulary::std_vocabulary;
|
||||
@@ -136,7 +136,7 @@ fn format_op_error(e: &OpError) -> String {
|
||||
/// a built `Composite` — or a `op N (kind): cause` message (a per-op fault,
|
||||
/// attributed by the retained op-kind list) / `finalize: cause` (a holistic fault
|
||||
/// past the last op).
|
||||
fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<Composite, String> {
|
||||
fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
|
||||
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 ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
|
||||
@@ -162,14 +162,14 @@ fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<Composite,
|
||||
|
||||
/// Parse a JSON op-list document, replay it through the env's vocabulary, and
|
||||
/// return the emitted #155 blueprint JSON — fault shapes as in `composite_from_str`.
|
||||
pub fn build_from_str(doc: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
pub fn build_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
let composite = composite_from_str(doc, env)?;
|
||||
blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))
|
||||
}
|
||||
|
||||
/// `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.
|
||||
pub fn build_cmd(env: &crate::project::Env) {
|
||||
pub fn build_cmd(env: &aura_runner::project::Env) {
|
||||
use std::io::Read;
|
||||
let mut doc = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
|
||||
@@ -192,7 +192,7 @@ pub fn build_cmd(env: &crate::project::Env) {
|
||||
/// `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
|
||||
/// closed vocabulary.
|
||||
pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
|
||||
let schema = builder.schema();
|
||||
let mut out = format!("{}\n", builder.label());
|
||||
@@ -210,7 +210,7 @@ pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<Strin
|
||||
|
||||
/// `aura graph introspect --unwired`: the still-open interior slots of a partial
|
||||
/// op-list document, by-identifier (applies the ops, does NOT finalize).
|
||||
pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
||||
let resolver = |t: &str| env.resolve(t);
|
||||
let mut session = GraphSession::new("introspect", &resolver);
|
||||
@@ -229,7 +229,7 @@ pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String
|
||||
/// group must be set; zero or more than one is the usage error (exit 2). The id
|
||||
/// group is `--content-id [FILE]` and/or `--identity-id` — the two id flags may
|
||||
/// combine (one build, both ids, content id first).
|
||||
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) {
|
||||
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project::Env) {
|
||||
let count = cmd.vocabulary as usize
|
||||
+ cmd.node.is_some() as usize
|
||||
+ cmd.unwired as usize
|
||||
@@ -404,7 +404,7 @@ fn gang_fault_prose(e: &CompileError) -> String {
|
||||
/// (`unresolved_namespace_hint`, over `LoadError`) and the op-script `graph
|
||||
/// build` path (`composite_from_str`, over `OpError`) call this same helper,
|
||||
/// so the tier texts cannot drift between the two error families.
|
||||
fn tier_hint_for_type_id(type_id: &str, env: &crate::project::Env) -> Option<String> {
|
||||
fn tier_hint_for_type_id(type_id: &str, env: &aura_runner::project::Env) -> Option<String> {
|
||||
if !type_id.contains("::") {
|
||||
return None;
|
||||
}
|
||||
@@ -423,7 +423,7 @@ fn tier_hint_for_type_id(type_id: &str, env: &crate::project::Env) -> Option<Str
|
||||
|
||||
/// (#185/#241) See [`tier_hint_for_type_id`] for the tier rules; this is the
|
||||
/// blueprint LOAD path's entry point over `LoadError`.
|
||||
pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env) -> Option<String> {
|
||||
pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &aura_runner::project::Env) -> Option<String> {
|
||||
match e {
|
||||
LoadError::UnknownNodeType(t) => tier_hint_for_type_id(t, env),
|
||||
_ => None,
|
||||
@@ -447,7 +447,7 @@ pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env
|
||||
/// `Ok(())` means the document loaded cleanly; callers that only need the
|
||||
/// validation (not the `Composite`) re-parse `doc` themselves afterward
|
||||
/// (`blueprint_from_json` is cheap and infallible at that point).
|
||||
pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Result<(), String> {
|
||||
pub(crate) fn blueprint_slot_prose(doc: &str, env: &aura_runner::project::Env) -> Result<(), String> {
|
||||
if matches!(serde_json::from_str::<serde_json::Value>(doc), Ok(serde_json::Value::Array(_))) {
|
||||
return Err(
|
||||
"this is an op-script (an op array), not a built blueprint — run \
|
||||
@@ -469,7 +469,7 @@ pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Resu
|
||||
/// envelope (a JSON object: `format_version` + `blueprint`) OR a construction
|
||||
/// op-list (a JSON array) — shape-discriminated on the top-level JSON type,
|
||||
/// each canonicalized by its own rules.
|
||||
pub(crate) fn composite_from_any(text: &str, env: &crate::project::Env) -> Result<Composite, String> {
|
||||
pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?;
|
||||
match value {
|
||||
@@ -488,22 +488,22 @@ pub(crate) fn composite_from_any(text: &str, env: &crate::project::Env) -> Resul
|
||||
|
||||
/// Resolve a blueprint document's bytes from a file path or a 64-hex content
|
||||
/// id in the project store (the campaign-run target-addressing convention).
|
||||
/// The id shape is `crate::campaign_run::is_content_id` — the same predicate
|
||||
/// The id shape is `aura_runner::axes::is_content_id` — the same predicate
|
||||
/// `campaign run`'s target resolution uses, so the two FILE-or-id surfaces
|
||||
/// cannot drift apart on what counts as a store address.
|
||||
fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
// A CLI arg tolerates the `content:` display prefix (#194); doc ref
|
||||
// fields stay bare-only.
|
||||
let target = target
|
||||
.strip_prefix("content:")
|
||||
.filter(|t| crate::campaign_run::is_content_id(t))
|
||||
.filter(|t| aura_runner::axes::is_content_id(t))
|
||||
.unwrap_or(target);
|
||||
let path = Path::new(target);
|
||||
if path.is_file() {
|
||||
return std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("cannot read {}: {e}", path.display()));
|
||||
}
|
||||
if crate::campaign_run::is_content_id(target) {
|
||||
if aura_runner::axes::is_content_id(target) {
|
||||
return match env.registry().get_blueprint(target) {
|
||||
Ok(Some(json)) => Ok(json),
|
||||
Ok(None) => Err(format!("no blueprint {target} in the project store")),
|
||||
@@ -518,7 +518,7 @@ fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<Str
|
||||
/// campaign-axis namespace `validate_campaign_refs` checks axes against. The
|
||||
/// kind renders via `ScalarKind`'s `Debug` (`I64`/`F64`/`Bool`/`Timestamp`),
|
||||
/// the same form `introspect --node` already uses for param kinds.
|
||||
fn params_lines(target: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
use std::fmt::Write as _;
|
||||
let text = resolve_blueprint_text(target, env)?;
|
||||
// Shape-discriminated like file-mode --content-id: an op-script (array)
|
||||
@@ -535,7 +535,7 @@ fn params_lines(target: &str, env: &crate::project::Env) -> Result<String, Strin
|
||||
/// the project vocabulary, canonicalize, content-address, and store — the
|
||||
/// `process register` pattern, printing the store path so the trail is
|
||||
/// followable. Prose to stderr + exit 1 on any refusal.
|
||||
pub fn register_cmd(file: &Path, env: &crate::project::Env) {
|
||||
pub fn register_cmd(file: &Path, env: &aura_runner::project::Env) {
|
||||
match register_blueprint(file, env) {
|
||||
Ok(line) => println!("{line}"),
|
||||
Err(m) => {
|
||||
@@ -545,7 +545,7 @@ pub fn register_cmd(file: &Path, env: &crate::project::Env) {
|
||||
}
|
||||
}
|
||||
|
||||
fn register_blueprint(file: &Path, env: &crate::project::Env) -> Result<String, String> {
|
||||
fn register_blueprint(file: &Path, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
let text = std::fs::read_to_string(file)
|
||||
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
|
||||
// Shape-discriminated like file-mode --content-id (#202): either shape
|
||||
@@ -605,7 +605,7 @@ mod tests {
|
||||
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("valid document builds");
|
||||
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("valid document builds");
|
||||
assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}");
|
||||
assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}");
|
||||
}
|
||||
@@ -621,14 +621,14 @@ mod tests {
|
||||
{"op":"feed","role":"price","into":["fast.series"]},
|
||||
{"op":"expose","from":"fast.value","as":"out"}
|
||||
]"#;
|
||||
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("name-keyed add builds");
|
||||
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("name-keyed add builds");
|
||||
assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
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 err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
assert_eq!(err, "op 1 (add): unknown node type \"Nope\"");
|
||||
}
|
||||
|
||||
@@ -643,7 +643,7 @@ mod tests {
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||
]"#;
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
assert!(err.starts_with("finalize: "), "finalize fault, got: {err}");
|
||||
assert!(err.contains("sub.rhs") && err.contains("is unconnected"),
|
||||
"names the unconnected slot by-identifier, got: {err}");
|
||||
@@ -652,11 +652,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn introspect_node_lists_ports_kinds_and_params() {
|
||||
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
assert!(out.contains("series"), "lists the input port: {out}");
|
||||
assert!(out.contains("value"), "lists the output field: {out}");
|
||||
assert!(out.contains("length"), "lists the param path: {out}");
|
||||
assert!(super::introspect_node("Nope", &crate::project::Env::std()).is_err(), "rejects an unknown type");
|
||||
assert!(super::introspect_node("Nope", &aura_runner::project::Env::std()).is_err(), "rejects an unknown type");
|
||||
}
|
||||
|
||||
/// A bind whose value-kind mismatches the param reads as prose, like the connect
|
||||
@@ -664,7 +664,7 @@ mod tests {
|
||||
#[test]
|
||||
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 err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64");
|
||||
}
|
||||
|
||||
@@ -673,7 +673,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_from_str_unknown_bind_param_reads_as_prose() {
|
||||
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#;
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
assert_eq!(err, "op 0 (add): node fast has no param \"window\"");
|
||||
}
|
||||
|
||||
@@ -694,7 +694,7 @@ mod tests {
|
||||
/// does not have to read source to learn the `{"I64": <v>}` wrapping.
|
||||
#[test]
|
||||
fn introspect_node_shows_the_bind_form() {
|
||||
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
assert!(out.contains("(bind {\"I64\": <v>})"), "shows the bind-value form: {out}");
|
||||
}
|
||||
|
||||
@@ -705,7 +705,7 @@ mod tests {
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||
]"#;
|
||||
let out = super::introspect_unwired(doc, &crate::project::Env::std()).expect("partial document introspects");
|
||||
let out = super::introspect_unwired(doc, &aura_runner::project::Env::std()).expect("partial document introspects");
|
||||
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("sub.lhs"), "sub.lhs is covered: {out}");
|
||||
@@ -732,7 +732,7 @@ mod tests {
|
||||
assert_eq!(docs[7].kind_label(), "tap");
|
||||
// and the built blueprint carries the tap (an un-tapped composite emits no
|
||||
// `taps` key, so its presence is the proof the op threaded through)
|
||||
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("tap op-doc builds");
|
||||
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("tap op-doc builds");
|
||||
assert!(json.contains("\"taps\""), "blueprint carries the taps section: {json}");
|
||||
assert!(json.contains("fast_ma"), "the tap names the wire: {json}");
|
||||
}
|
||||
|
||||
+211
-3185
File diff suppressed because it is too large
Load Diff
@@ -1,906 +0,0 @@
|
||||
//! 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_vocabulary::{std_vocabulary, std_vocabulary_types};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
/// 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,
|
||||
#[serde(default)]
|
||||
pub nodes: AuraNodes,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
/// Node-crate pointers (C17: static context). Paths resolve relative to the
|
||||
/// project root (`Path::join`, so absolute paths pass through). The format
|
||||
/// carries a list; this iteration loads exactly one entry and refuses more.
|
||||
#[derive(Debug, Default, PartialEq, serde::Deserialize)]
|
||||
pub struct AuraNodes {
|
||||
#[serde(default)]
|
||||
pub crates: Vec<String>,
|
||||
}
|
||||
|
||||
#[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),
|
||||
MultiCrate(usize),
|
||||
NodesPointer { pointer: String, inner: Box<ProjectError> },
|
||||
/// #245: the `[nodes]` pointer target does not exist — checked before
|
||||
/// handing off to `load_crate`, so this never reaches the cargo-metadata
|
||||
/// probe (whose raw os-error text would otherwise leak through).
|
||||
PointerDirMissing(PathBuf),
|
||||
}
|
||||
|
||||
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}"),
|
||||
Self::MultiCrate(n) => write!(
|
||||
f,
|
||||
"multi-crate loading is not yet supported (found {n} entries \
|
||||
in [nodes].crates — only one is accepted)"
|
||||
),
|
||||
Self::NodesPointer { pointer, inner } => write!(
|
||||
f,
|
||||
"node crate at `{pointer}` (from [nodes] in Aura.toml): {inner}"
|
||||
),
|
||||
Self::PointerDirMissing(p) => write!(f, "{} does not exist", p.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A discovered project. `native` is present only when a node crate is
|
||||
/// loaded (the crate's `Library` is leaked — load-and-hold, 'static).
|
||||
#[derive(Debug)]
|
||||
pub struct ProjectEnv {
|
||||
pub root: PathBuf,
|
||||
pub toml: AuraToml,
|
||||
pub commit: Option<String>,
|
||||
pub native: Option<NativeEnv>,
|
||||
}
|
||||
|
||||
/// The loaded node crate: vocabulary + identity stamps.
|
||||
#[derive(Debug)]
|
||||
pub struct NativeEnv {
|
||||
pub namespace: String,
|
||||
pub dylib_sha256: String,
|
||||
resolver: fn(&str) -> Option<PrimitiveBuilder>,
|
||||
type_id_list: &'static [&'static str],
|
||||
}
|
||||
|
||||
impl ProjectEnv {
|
||||
fn resolve(&self, type_id: &str) -> Option<PrimitiveBuilder> {
|
||||
self.native.as_ref().and_then(|n| (n.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(n) = self.project.as_ref().and_then(|p| p.native.as_ref()) {
|
||||
out.extend_from_slice(n.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
|
||||
/// (relative values resolved against the project root, same as
|
||||
/// `runs_root()` and the `[nodes]` pointers), the data-server default
|
||||
/// otherwise.
|
||||
pub fn data_path(&self) -> String {
|
||||
self.project
|
||||
.as_ref()
|
||||
.and_then(|p| {
|
||||
p.toml.paths.data.as_ref().map(|data| {
|
||||
let path = Path::new(data);
|
||||
if path.is_absolute() {
|
||||
data.clone()
|
||||
} else {
|
||||
p.root.join(path).to_string_lossy().to_string()
|
||||
}
|
||||
})
|
||||
})
|
||||
.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.native.as_ref().map(|n| n.namespace.clone()),
|
||||
dylib_sha256: p.native.as_ref().map(|n| n.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()))
|
||||
}
|
||||
|
||||
/// Read a project's `Aura.toml` (for scaffold-side tooling that must not
|
||||
/// trigger a crate load, e.g. `aura nodes new`).
|
||||
pub fn read_aura_toml(root: &Path) -> Result<AuraToml, ProjectError> {
|
||||
parse_aura_toml(root)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Render a `SystemTime` as a human-readable UTC timestamp with a 4-digit
|
||||
/// year (`YYYY-MM-DD HH:MM:SS UTC`). Hand-rolled (Howard Hinnant's
|
||||
/// civil-from-days algorithm) rather than pulling in a date-formatting
|
||||
/// dependency for this one warning line.
|
||||
fn format_mtime(t: SystemTime) -> String {
|
||||
let secs = t
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
let days = secs.div_euclid(86_400);
|
||||
let day_secs = secs.rem_euclid(86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!(
|
||||
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} UTC",
|
||||
day_secs / 3600,
|
||||
(day_secs % 3600) / 60,
|
||||
day_secs % 60
|
||||
)
|
||||
}
|
||||
|
||||
/// Days-since-epoch -> (year, month, day), UTC civil calendar. See
|
||||
/// <https://howardhinnant.github.io/date_algorithms.html#civil_from_days>.
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64; // [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
/// The role-2 authoring loop's staleness check (#237): if `source_mtime` is
|
||||
/// strictly newer than `dylib_mtime`, a forgotten `cargo build` would run the
|
||||
/// previous dylib and report plausible-but-stale numbers. Returns the warning
|
||||
/// line naming both timestamps, or `None` when the dylib is not stale (the
|
||||
/// only case where this fires — a fresh build stays silent).
|
||||
fn stale_warning(
|
||||
dylib_path: &Path,
|
||||
dylib_mtime: SystemTime,
|
||||
source_mtime: SystemTime,
|
||||
) -> Option<String> {
|
||||
if source_mtime > dylib_mtime {
|
||||
Some(format!(
|
||||
"warning: {} may be stale — dylib built {} but a project source \
|
||||
file was modified {} (run `cargo build` to refresh); proceeding \
|
||||
with the existing dylib",
|
||||
dylib_path.display(),
|
||||
format_mtime(dylib_mtime),
|
||||
format_mtime(source_mtime),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest modification time among the project's `Cargo.toml` and every
|
||||
/// file under its `src/` tree (the set that, changed, invalidates the built
|
||||
/// dylib). `None` if neither exists / is readable — never fatal, staleness
|
||||
/// is a warning, not a refusal.
|
||||
fn newest_source_mtime(root: &Path) -> Option<SystemTime> {
|
||||
let mut newest: Option<SystemTime> = None;
|
||||
let mut consider = |t: SystemTime| {
|
||||
if newest.is_none_or(|n| t > n) {
|
||||
newest = Some(t);
|
||||
}
|
||||
};
|
||||
if let Ok(meta) = std::fs::metadata(root.join("Cargo.toml"))
|
||||
&& let Ok(t) = meta.modified()
|
||||
{
|
||||
consider(t);
|
||||
}
|
||||
let mut stack = vec![root.join("src")];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
match entry.file_type() {
|
||||
Ok(ft) if ft.is_dir() => stack.push(path),
|
||||
Ok(_) => {
|
||||
if let Ok(t) = entry.metadata().and_then(|m| m.modified()) {
|
||||
consider(t);
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
newest
|
||||
}
|
||||
|
||||
/// Discover the project's tier and load it. Data-only (no `[nodes]`, no root
|
||||
/// crate) loads nothing; a `[nodes]` pointer or a root Cargo.toml routes into
|
||||
/// the unchanged crate pipeline (`load_crate`).
|
||||
pub fn load(root: &Path, release: bool) -> Result<ProjectEnv, ProjectError> {
|
||||
let toml = parse_aura_toml(root)?;
|
||||
let native = match toml.nodes.crates.as_slice() {
|
||||
[] => {
|
||||
if root.join("Cargo.toml").is_file() {
|
||||
Some(load_crate(root, release)?)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
[pointer] => {
|
||||
let dir = root.join(pointer);
|
||||
if !dir.is_dir() {
|
||||
return Err(ProjectError::NodesPointer {
|
||||
pointer: pointer.clone(),
|
||||
inner: Box::new(ProjectError::PointerDirMissing(dir)),
|
||||
});
|
||||
}
|
||||
Some(load_crate(&dir, release).map_err(|e| ProjectError::NodesPointer {
|
||||
pointer: pointer.clone(),
|
||||
inner: Box::new(e),
|
||||
})?)
|
||||
}
|
||||
many => return Err(ProjectError::MultiCrate(many.len())),
|
||||
};
|
||||
Ok(ProjectEnv {
|
||||
root: root.to_path_buf(),
|
||||
toml,
|
||||
commit: project_commit(root),
|
||||
native,
|
||||
})
|
||||
}
|
||||
|
||||
/// Locate, load (load-and-hold), verify, and charter-check a node crate's
|
||||
/// dylib.
|
||||
fn load_crate(crate_root: &Path, release: bool) -> Result<NativeEnv, ProjectError> {
|
||||
let dylib_path = artifact_path(crate_root, release)?;
|
||||
if !dylib_path.is_file() {
|
||||
return Err(ProjectError::ArtifactMissing(dylib_path));
|
||||
}
|
||||
if let Ok(dylib_mtime) = std::fs::metadata(&dylib_path).and_then(|m| m.modified())
|
||||
&& let Some(source_mtime) = newest_source_mtime(crate_root)
|
||||
&& let Some(warning) = stale_warning(&dylib_path, dylib_mtime, source_mtime)
|
||||
{
|
||||
eprintln!("{warning}");
|
||||
}
|
||||
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(NativeEnv { namespace, dylib_sha256, resolver, type_id_list })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn discover_walks_up_to_aura_toml() {
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-disc");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
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_a_nodes_pointer_list() {
|
||||
let t: AuraToml = toml::from_str("[nodes]\ncrates = [\"../my-nodes\"]").unwrap();
|
||||
assert_eq!(t.nodes.crates, vec!["../my-nodes".to_string()]);
|
||||
let t: AuraToml = toml::from_str("").unwrap();
|
||||
assert!(t.nodes.crates.is_empty());
|
||||
}
|
||||
|
||||
/// #241: an Aura.toml with neither a [nodes] pointer nor a root crate is
|
||||
/// the data-only tier — it loads with no native env, resolves std-only,
|
||||
/// and stamps commit-only provenance.
|
||||
#[test]
|
||||
fn data_only_project_loads_without_a_crate() {
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-dataonly");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
std::fs::write(tmp.join("Aura.toml"), "[paths]\nruns = \"runs\"\n").unwrap();
|
||||
let p = load(&tmp, false).expect("data-only load");
|
||||
assert!(p.native.is_none());
|
||||
let env = Env::with_project(p);
|
||||
assert!(env.resolve("SMA").is_some());
|
||||
assert!(env.resolve("demo::Identity").is_none());
|
||||
assert_eq!(env.runs_root(), tmp.join("runs"));
|
||||
let prov = env.provenance().expect("a data-only project is a project");
|
||||
assert!(prov.namespace.is_none());
|
||||
assert!(prov.dylib_sha256.is_none());
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
/// #254: a relative `paths.data` must resolve against the project root
|
||||
/// (`p.root`), matching `runs_root()`'s and the `[nodes]` pointers'
|
||||
/// resolution — not against the invoking process's cwd, which silently
|
||||
/// changes the data location depending on the caller's working directory.
|
||||
#[test]
|
||||
fn data_path_resolves_a_relative_paths_data_against_the_project_root() {
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-datarel");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
std::fs::write(tmp.join("Aura.toml"), "[paths]\ndata = \"data\"\n").unwrap();
|
||||
let p = load(&tmp, false).expect("data-only load");
|
||||
let env = Env::with_project(p);
|
||||
assert_eq!(env.data_path(), tmp.join("data").to_string_lossy().to_string());
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_nodes_pointers_refuse_with_multi_crate() {
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-multi");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"a\", \"b\"]\n").unwrap();
|
||||
let e = load(&tmp, false).unwrap_err();
|
||||
assert!(e.to_string().contains("multi-crate loading is not yet supported"), "{e}");
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_nodes_pointer_names_the_pointer() {
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-badptr");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"../nope\"]\n").unwrap();
|
||||
let e = load(&tmp, false).unwrap_err();
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("../nope"), "{msg}");
|
||||
assert!(msg.contains("[nodes]"), "{msg}");
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
/// #245: a `[nodes]` pointer at a directory that does not exist refuses
|
||||
/// with a clean message — it names the missing directory and keeps the
|
||||
/// `[nodes]` pointer context, but does NOT leak the raw `cargo metadata`
|
||||
/// toolchain error. That wrap is reserved for a directory that exists but
|
||||
/// is not a valid crate; a plainly-absent directory should say exactly
|
||||
/// that (`load` still refuses → CLI exit 1).
|
||||
#[test]
|
||||
fn a_missing_nodes_pointer_dir_refuses_without_the_cargo_metadata_leak() {
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-missingptr");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
// `missing-node-crate` is never created — the pointer target is absent.
|
||||
std::fs::write(
|
||||
tmp.join("Aura.toml"),
|
||||
"[nodes]\ncrates = [\"missing-node-crate\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let e = load(&tmp, false).unwrap_err();
|
||||
let msg = e.to_string();
|
||||
// names the offending pointer path ...
|
||||
assert!(msg.contains("missing-node-crate"), "names the pointer path: {msg}");
|
||||
// ... says plainly that the directory is not there ...
|
||||
assert!(msg.contains("does not exist"), "states the directory is absent: {msg}");
|
||||
// ... keeps the [nodes] pointer context ...
|
||||
assert!(msg.contains("[nodes]"), "keeps the pointer context: {msg}");
|
||||
// ... and does not leak the raw toolchain error for a simple absence.
|
||||
assert!(
|
||||
!msg.contains("cargo metadata"),
|
||||
"no cargo-metadata leak for an absent directory: {msg}"
|
||||
);
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
|
||||
/// The role-2 authoring-loop staleness check (#237) is a pure comparison:
|
||||
/// a source mtime strictly newer than the dylib's produces a warning
|
||||
/// naming both timestamps in human-readable (4-digit-year) form.
|
||||
#[test]
|
||||
fn stale_warning_fires_and_names_both_timestamps_when_source_is_newer() {
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
let dylib_mtime = UNIX_EPOCH + Duration::from_secs(993_859_200); // 2001-06-30
|
||||
let source_mtime = UNIX_EPOCH + Duration::from_secs(2_003_702_400); // 2033-06-30
|
||||
let path = PathBuf::from("/tmp/x.so");
|
||||
let msg = stale_warning(&path, dylib_mtime, source_mtime).expect("source is newer");
|
||||
assert!(msg.contains("2001"), "names the dylib's mtime: {msg}");
|
||||
assert!(msg.contains("2033"), "names the source's newer mtime: {msg}");
|
||||
}
|
||||
|
||||
/// The inverse of the above: a fresh (or equally-timed) dylib produces no
|
||||
/// warning at all — staleness is the only trigger, never routine builds.
|
||||
#[test]
|
||||
fn stale_warning_is_silent_when_source_is_not_newer() {
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
let t = UNIX_EPOCH + Duration::from_secs(1_000_000_000);
|
||||
let path = PathBuf::from("/tmp/x.so");
|
||||
assert!(stale_warning(&path, t, t).is_none(), "equal mtimes: not stale");
|
||||
assert!(
|
||||
stale_warning(&path, t, t - Duration::from_secs(10)).is_none(),
|
||||
"older source: not stale"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -234,7 +234,7 @@ mod tests {
|
||||
/// against the prototype.
|
||||
#[test]
|
||||
fn render_html_is_self_contained_and_embeds_the_model() {
|
||||
let env = crate::project::Env::std();
|
||||
let env = aura_runner::project::Env::std();
|
||||
let bp = aura_engine::blueprint_from_json(
|
||||
include_str!("../examples/r_sma.json"),
|
||||
&|t| env.resolve(t),
|
||||
|
||||
@@ -13,7 +13,7 @@ use aura_research::{
|
||||
};
|
||||
use aura_registry::RefFault;
|
||||
|
||||
use crate::project::Env;
|
||||
use aura_runner::project::Env;
|
||||
|
||||
#[derive(clap::Args)]
|
||||
pub struct ProcessCmd {
|
||||
|
||||
@@ -53,13 +53,15 @@ pub(crate) struct SugarInvocation<'a> {
|
||||
/// by construction, so the net channel is never producible there; requesting
|
||||
/// it anyway would make `campaign_run`'s executor print an unreachable "add a
|
||||
/// cost block to the campaign document" remedy (#240). The exclusion reuses
|
||||
/// `campaign_run`'s own tap/channel classification (`tap_channel`/
|
||||
/// `aura_runner::runner`'s own tap/channel classification (`tap_channel`/
|
||||
/// `TapChannel::Net`) rather than duplicating the net-tap name here.
|
||||
fn persist_taps_from(trace: bool) -> Vec<String> {
|
||||
if trace {
|
||||
tap_vocabulary()
|
||||
.iter()
|
||||
.filter(|t| crate::campaign_run::tap_channel(t) != Some(crate::campaign_run::TapChannel::Net))
|
||||
.filter(|t| {
|
||||
aura_runner::runner::tap_channel(t) != Some(aura_runner::runner::TapChannel::Net)
|
||||
})
|
||||
.map(|t| t.to_string())
|
||||
.collect()
|
||||
} else {
|
||||
@@ -285,8 +287,8 @@ pub(crate) fn register_generated_g(
|
||||
/// (campaign_run.rs) — the referential shape the dispatch-boundary name
|
||||
/// preflight (main.rs) does not catch: a subset of axes that leaves an open
|
||||
/// param unbound (probe P3). Probe P3's space is the REOPENED one (#246): the
|
||||
/// same `raw_bound_overrides_of` derivation `CliMemberRunner::run_member` uses
|
||||
/// per member (campaign_run.rs) — a bound-param axis passes this
|
||||
/// same `raw_bound_overrides_of` derivation `DefaultMemberRunner::run_member`
|
||||
/// uses per member (aura-runner) — a bound-param axis passes this
|
||||
/// executable-shape preflight exactly like an already-open one; an axis
|
||||
/// matching neither space still fails `bind_axes`'s own named check below.
|
||||
/// `probe_params` is any one grid value per axis (checks NAME
|
||||
@@ -298,7 +300,7 @@ fn validate_before_register(
|
||||
campaign: &CampaignDoc,
|
||||
blueprint_canonical: &str,
|
||||
probe_params: &[(String, Scalar)],
|
||||
env: &crate::project::Env,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<(), String> {
|
||||
let doc_faults = aura_research::validate_campaign(campaign);
|
||||
if !doc_faults.is_empty() {
|
||||
@@ -324,10 +326,10 @@ fn validate_before_register(
|
||||
let raw_signal = aura_engine::blueprint_from_json(blueprint_canonical, &|t| env.resolve(t))
|
||||
.expect("a generated sugar campaign's strategy ref reloads its own canonical bytes");
|
||||
let param_names: Vec<String> = probe_params.iter().map(|(n, _)| n.clone()).collect();
|
||||
let overrides = crate::campaign_run::raw_bound_overrides_of(¶m_names, &raw_space, &raw_signal);
|
||||
let overrides = aura_runner::axes::raw_bound_overrides_of(¶m_names, &raw_space, &raw_signal);
|
||||
let space = crate::blueprint_axis_probe_reopened(blueprint_canonical, env, &overrides).param_space();
|
||||
let strategy_id = content_id_of(blueprint_canonical);
|
||||
crate::campaign_run::bind_axes(&space, &strategy_id, probe_params)
|
||||
aura_runner::axes::bind_axes(&space, &strategy_id, probe_params)
|
||||
.map(|_| ())
|
||||
.map_err(|f| crate::campaign_run::exec_fault_prose(&aura_campaign::ExecFault::Member(f)))
|
||||
}
|
||||
@@ -338,7 +340,7 @@ fn validate_before_register(
|
||||
/// one campaign executor.
|
||||
pub(crate) fn run_sweep_sugar(
|
||||
inv: &SugarInvocation,
|
||||
env: &crate::project::Env,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
let generated = translate_sweep(inv)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
@@ -384,7 +386,7 @@ fn cross_instrument_members(
|
||||
pub(crate) fn run_generalize_sugar(
|
||||
inv: &SugarInvocation,
|
||||
metric: &str,
|
||||
env: &crate::project::Env,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
let generated = translate_generalize(inv, metric)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
@@ -510,7 +512,7 @@ pub(crate) fn run_walkforward_sugar(
|
||||
metric: &str,
|
||||
w: WfWindows,
|
||||
select: SelectRule,
|
||||
env: &crate::project::Env,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
let generated = translate_walkforward(inv, metric, w, select)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
@@ -563,7 +565,7 @@ pub(crate) fn run_walkforward_sugar(
|
||||
// always `None`. `run`/`mc --trace` stay refused by design (#224), not by
|
||||
// this tail being skipped.
|
||||
if let Some(trace_name) = &run.outcome.record.trace_name {
|
||||
crate::campaign_run::persist_campaign_traces(
|
||||
aura_runner::runner::persist_campaign_traces(
|
||||
trace_name,
|
||||
&run.campaign.presentation.persist_taps,
|
||||
&run.outcome,
|
||||
@@ -674,7 +676,7 @@ pub(crate) fn run_mc_sugar(
|
||||
metric: &str,
|
||||
w: WfWindows,
|
||||
mc: McKnobs,
|
||||
env: &crate::project::Env,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
let generated = translate_mc(inv, metric, w, mc)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
|
||||
Reference in New Issue
Block a user