diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 844b083..ab799bb 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1120,15 +1120,19 @@ with `aura graph build < {path}`, then exec the result" // a process document) names the found kind and exec's two // executable classes, instead of the campaign leg's generic // "kind" key must be "campaign" (which never says WHAT was found or - // what exec accepts at all). Same exit-code class as today's - // fall-through refusal (runtime-class, 1 — mirrors - // `exit_on_campaign_result`'s `Err` arm). + // what exec accepts at all). Exit 2, not 1 (#342 item 5, C14 + // realignment): the target is a real, existing, valid-JSON file, so + // its content is itself part of the argument (C14's usage-class + // rule) — the same class as the sibling OpScript and + // not-valid-JSON file-content faults right below/above, not the + // runtime class reserved for a target that resolves to no real + // state at all (a missing file or an unregistered content id). (Some(path), Some(ExecFileShape::WrongKind(found))) => { eprintln!( "aura: exec: {path}: this document's kind is \"{found}\", but exec executes \ only a campaign document or a fully-bound blueprint" ); - std::process::exit(1); + std::process::exit(2); } (Some(path), Some(ExecFileShape::Blueprint)) => { exec_blueprint_leg(path, &a.r#override, &a.tap, env) diff --git a/crates/aura-cli/tests/exec.rs b/crates/aura-cli/tests/exec.rs index 1a30820..5fe8e48 100644 --- a/crates/aura-cli/tests/exec.rs +++ b/crates/aura-cli/tests/exec.rs @@ -285,17 +285,24 @@ fn exec_op_script_file_refuses_with_build_hint_not_invalid_json() { ); } -/// #342 item 4: a kind-bearing document that is not a campaign (here, a -/// process document) refuses naming the found kind and exec's two +/// #342 item 4/item 5: a kind-bearing document that is not a campaign (here, +/// a process document) refuses naming the found kind and exec's two /// executable classes, instead of the campaign leg's generic "the \"kind\" /// key must be \"campaign\"" — which never says WHAT was found, nor what -/// exec accepts at all. +/// exec accepts at all. Exit 2, not 1 (C14 exit-partition realignment, +/// #342 item 5): the target IS a real, existing, valid-JSON file, so its +/// content is itself part of the argument (C14's usage-class rule) — the +/// same class as the op-script and not-valid-JSON file-content faults +/// (`exec_op_script_file_refuses_with_build_hint_not_invalid_json`, +/// `exec_malformed_json_file_refuses_neutrally_not_misrouted`), not the +/// runtime class reserved for a target that resolves to no real state at +/// all (missing file / unregistered content id). #[test] fn exec_process_document_names_found_kind_and_executable_classes() { let dir = temp_cwd("exec-wrong-kind-process"); write_doc(&dir, "p.process.json", SWEEP_ONLY_PROCESS_DOC); let (out, code) = run_code_in(&dir, &["exec", "p.process.json"]); - assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert_eq!(code, Some(2), "stdout/stderr: {out}"); assert!(out.contains("\"process\""), "must name the found kind: {out}"); assert!( out.contains("campaign document") && out.contains("fully-bound blueprint"), @@ -546,6 +553,54 @@ fn exec_blueprint_without_override_runs_bound_values_untouched() { ); } +/// Ratification pin (#343): an override IS a topology-parameter variation, +/// even a "no-op" one that re-binds a param to its EXISTING bound value — +/// `examples/r_sma.json` already binds `fast.length` to 2, so +/// `--override fast.length=2` changes nothing observable about the run's +/// *behaviour*, yet the reopened param moves from `manifest.defaults` to +/// `manifest.params` (the #246 reopen mechanism, unconditionally applied), +/// which changes the canonical bytes `topology_hash` covers: the two runs' +/// `topology_hash`es must therefore DIFFER, and the overridden run's own +/// hash must be stable across repeated runs (determinism, C1) — the +/// reopened topology is its own honest, reproducible identity, not a +/// dangling reference (see the ratification prose in C12/C24). +#[test] +fn exec_blueprint_noop_override_mints_a_distinct_but_stable_topology_hash() { + let (base_out, base_code) = run_code(&["exec", "examples/r_sma.json"]); + assert_eq!(base_code, Some(0), "stdout/stderr: {base_out}"); + let base_line = base_out.lines().find(|l| l.starts_with('{')).expect("record line"); + let base_v: serde_json::Value = serde_json::from_str(base_line).expect("record parses"); + let base_hash = + base_v["manifest"]["topology_hash"].as_str().expect("base topology_hash").to_string(); + + let (ov_out, ov_code) = + run_code(&["exec", "examples/r_sma.json", "--override", "fast.length=2"]); + assert_eq!(ov_code, Some(0), "stdout/stderr: {ov_out}"); + let ov_line = ov_out.lines().find(|l| l.starts_with('{')).expect("record line"); + let ov_v: serde_json::Value = serde_json::from_str(ov_line).expect("record parses"); + let ov_hash = + ov_v["manifest"]["topology_hash"].as_str().expect("overridden topology_hash").to_string(); + + assert_ne!( + base_hash, ov_hash, + "a no-op override still reopens the bound param, minting the reopened topology's own \ +hash: base {base_out} / overridden {ov_out}" + ); + + let (ov_out_2, ov_code_2) = + run_code(&["exec", "examples/r_sma.json", "--override", "fast.length=2"]); + assert_eq!(ov_code_2, Some(0), "stdout/stderr: {ov_out_2}"); + let ov_line_2 = ov_out_2.lines().find(|l| l.starts_with('{')).expect("record line"); + let ov_v_2: serde_json::Value = serde_json::from_str(ov_line_2).expect("record parses"); + let ov_hash_2 = + ov_v_2["manifest"]["topology_hash"].as_str().expect("overridden topology_hash (2)"); + assert_eq!( + ov_hash, ov_hash_2, + "the overridden run's topology_hash is stable across repeated runs (determinism, C1): \ +{ov_out} / {ov_out_2}" + ); +} + /// A malformed override token (no `NODE.PARAM=VALUE` shape) refuses as usage. #[test] fn exec_override_malformed_token_refuses_with_usage() { @@ -1523,6 +1578,26 @@ fn exec_json_suffixed_nonexistent_target_is_not_read_as_a_blueprint() { ); } +/// A target that IS a well-formed 64-hex content id, but names no +/// registered campaign, refuses at runtime (exit 1, C14 — #342 item 5): no +/// file is ever read for a bare id target, so there is no argument +/// *content* to have gotten wrong (contrast the wrong-kind / op-script / +/// not-valid-JSON FILE-content faults above, all exit 2) — only recorded +/// state (a registered campaign under this id) that turns out to be +/// absent, the same runtime class as the missing-file / stray-token cases +/// immediately above. +#[test] +fn exec_well_formed_but_unregistered_content_id_refuses_at_runtime() { + let (dir, _fixture) = fresh_project(); + let unregistered = "a".repeat(64); + let (out, code) = run_code_in(&dir, &["exec", &unregistered]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!( + out.contains(&format!("no campaign {unregistered} in the project store")), + "stdout/stderr: {out}" + ); +} + /// The usage/runtime exit-code partition (#175 iteration 2, deviation #8), /// ported onto exec's own grammar (mirrors /// `cli_run.rs::exit_codes_partition_usage_two_from_runtime_one`, whose diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index a36f998..d4916de 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -361,6 +361,60 @@ at all — the same closed-args-then-bind order every arg-bearing type follows introspect --params` as `combo.weights[1]:F64` (alongside `combo.weights[0]:F64 default=0.7`). +### Worked example: wiring a cost model (`ConstantCost` → `CostSum`) + +`CostSum`'s `cost[k].` inputs (C10) are meaningless fed from a bare +source: they read the co-temporal record a real `PositionManagement` node +emits (`closed`, `open`, `entry_price`, `stop_price`). Every node in that +chain — `Bias`, `FixedStop`, `Sizer`, `PositionManagement`, `ConstantCost`, +`CostSum` — sits in the closed vocabulary, so the whole cost-model wiring is +honestly expressible by hand, one `add`/`connect` pair at a time, mirroring +the Rust `risk_executor` + `cost_graph` composite-builders (`aura-composites`) +field-for-field: + +```json +[ + {"op": "source", "role": "price", "kind": "F64"}, + {"op": "source", "role": "signal", "kind": "F64"}, + {"op": "add", "type": "Bias", "name": "bias", "bind": {"scale": {"F64": 1.0}}}, + {"op": "feed", "role": "signal", "into": ["bias.signal"]}, + {"op": "add", "type": "FixedStop", "name": "stop", "bind": {"distance": {"F64": 0.01}}}, + {"op": "add", "type": "PositionManagement", "name": "pm"}, + {"op": "feed", "role": "price", "into": ["stop.price", "pm.price"]}, + {"op": "add", "type": "Sizer", "name": "sizer", "bind": {"risk_budget": {"F64": 1.0}}}, + {"op": "connect", "from": "bias.bias", "to": "sizer.bias"}, + {"op": "connect", "from": "stop.stop_distance", "to": "sizer.stop_distance"}, + {"op": "connect", "from": "bias.bias", "to": "pm.bias"}, + {"op": "connect", "from": "stop.stop_distance", "to": "pm.stop_distance"}, + {"op": "connect", "from": "sizer.size", "to": "pm.size"}, + {"op": "add", "type": "ConstantCost", "name": "cc", "bind": {"cost_per_trade": {"F64": 0.5}}}, + {"op": "connect", "from": "pm.closed_this_cycle", "to": "cc.closed"}, + {"op": "connect", "from": "pm.open", "to": "cc.open"}, + {"op": "connect", "from": "pm.entry_price", "to": "cc.entry_price"}, + {"op": "connect", "from": "pm.stop_price", "to": "cc.stop_price"}, + {"op": "add", "type": "CostSum", "name": "costsum", "args": {"n_costs": "1"}}, + {"op": "connect", "from": "cc.cost_in_r", "to": "costsum.cost[0].cost_in_r"}, + {"op": "connect", "from": "cc.cum_cost_in_r", "to": "costsum.cost[0].cum_cost_in_r"}, + {"op": "connect", "from": "cc.open_cost_in_r", "to": "costsum.cost[0].open_cost_in_r"}, + {"op": "expose", "from": "pm.realized_r", "as": "realized_r"}, + {"op": "expose", "from": "costsum.cost_in_r", "as": "cost_in_r"} +] +``` + +`CostSum`'s port names ARE the `cost[k].` shape already +(`cost[0].cost_in_r`, …) — `cost_port` is baked into `CostSum::make`'s schema +itself (#271), no composite-level renaming needed; a second factor is +`args: {"n_costs": "2"}` plus a second cost-node block wired into +`cost[1].*`, and `CostSum` per-field-sums whatever arity it was given (#341 +item 4). This closes the "CostSum reachable but pathless" gap: the ports +genuinely connect at the op-script level, using only closed-vocabulary +nodes. It is a standalone wiring reference, though, not the production +attachment path — a campaign strategy's real cost model still comes from +the campaign document's `cost:` block (C10, `cost_nodes_for`), wrapped +around the user's bias-only blueprint entirely in Rust at run time; a +user's op-script-authored blueprint never embeds `PositionManagement` / +cost nodes itself. + ### Session anchoring: the `SessionFrankfurt` preset To anchor logic to the trading session, the closed vocabulary ships a diff --git a/docs/design/contracts/c12-atomic-sim-unit.md b/docs/design/contracts/c12-atomic-sim-unit.md index af0505d..76a6eca 100644 --- a/docs/design/contracts/c12-atomic-sim-unit.md +++ b/docs/design/contracts/c12-atomic-sim-unit.md @@ -10,8 +10,11 @@ param — the family boundary re-opens it on the probe and on every member reload, and the axis binds it per cell; `exec`'s blueprint leg still requires every param resolved (a truly open param refuses), and its own `--override NODE.PARAM=VALUE` (#246 residue, #319) reopens exactly the named bound param -for that one execution — the single-member analogue of a campaign axis. -Identity is untouched: `content_id_of` (`crates/aura-research/src/lib.rs`) is +for that one execution — the single-member analogue of a campaign axis. The +reopened execution's `topology_hash` is therefore the reopened topology's own, +not the pre-override document's — ratified as the deliberate reading, not a +defect (#343; full ratification in [C24](c24-blueprint-data.md)'s +"Reproduction identity"). Identity is untouched: `content_id_of` (`crates/aura-research/src/lib.rs`) is the one hashing primitive, read directly off the authored document by `aura_runner::member`'s `run_signal_r`/`run_blueprint_member` — where the manifest's `topology_hash` is computed inline (#319 Task 9 retired the diff --git a/docs/design/contracts/c14-headless-two-faces.md b/docs/design/contracts/c14-headless-two-faces.md index 232dae0..7c2a042 100644 --- a/docs/design/contracts/c14-headless-two-faces.md +++ b/docs/design/contracts/c14-headless-two-faces.md @@ -75,6 +75,28 @@ The machine-first help surface (JSON/manifest help, stdin op-scripts) is deferred to the #157 / [C21](c21-world.md) track, distinct from this human/GNU-convention compliance. +**`exec`'s unresolved-target exit partition is pinned (#342 item 5).** A +target fault splits along the same usage/runtime line the exit-code partition +above already draws, keyed on whether a *real, existing document's content* +is being judged (usage, exit 2 — the content of an argv-named file is itself +part of the argument) or the target simply fails to resolve to any recorded +state at all (runtime, exit 1 — the needed environment/recorded state is +missing). Missing-state faults (exit 1): a target naming no readable file and +no plausible content id (`'{target}' is neither a readable .json file nor a +64-hex content id`), and a well-formed-but-unregistered 64-hex content id +(`no campaign {id} in the project store`) — no file or store entry exists to +have content in either case. File-content faults (exit 2): a file that does +not parse as JSON at all, a valid-JSON op-script array (not a document exec +can execute at all — it names the `graph build` escalation instead), and a +kind-bearing document whose `"kind"` is not `"campaign"` (e.g. a process +document handed to `exec`) — all three are a real, readable file whose +content is the fault. The wrong-kind case is the one realignment this pin +made (#342 item 5): it shipped at exit 1 (mirroring the campaign leg's own +missing-state refusals) until this pass, despite being a file-content fault +like its op-script and not-valid-JSON siblings, not a missing-state fault +like the file-vs-id cases — realigned to exit 2 to match its true class, the +one sanctioned behaviour change of this pin. + **Two artifact classes, two redundancy budgets** (#249, ratified 2026-07-13). The data layer the programmatic face emits is a public interface read raw (by humans and LLMs), and its records split into two classes with opposite diff --git a/docs/design/contracts/c18-registry.md b/docs/design/contracts/c18-registry.md index 81af12b..cf698c9 100644 --- a/docs/design/contracts/c18-registry.md +++ b/docs/design/contracts/c18-registry.md @@ -147,7 +147,13 @@ entry); name coincidence against one of the two namespaces IS the mechanism, and the referenced blueprint is the single source of truth for which namespace a name falls in. Pinned by `referential_tier_accepts_a_kind_correct_axis_over_a_bound_param` -(`aura-registry/src/lib.rs`). +(`aura-registry/src/lib.rs`). The single-run analogue — `exec`'s own +`--override NODE.PARAM=VALUE` reopening a bound param for one execution — has +the identical hash consequence, ratified rather than fixed (#343): the +executed topology's `topology_hash` is the reopened topology's own, not the +referenced document's, exactly as a reopened campaign member's has always +been ([C24](c24-blueprint-data.md)'s "Reproduction identity" carries the full +ratification). **The campaign executor.** `aura exec ` (#319) executes a campaign (a file is register-then-run sugar; the content id is canonical): a diff --git a/docs/design/contracts/c24-blueprint-data.md b/docs/design/contracts/c24-blueprint-data.md index 32b525e..c0a5c21 100644 --- a/docs/design/contracts/c24-blueprint-data.md +++ b/docs/design/contracts/c24-blueprint-data.md @@ -119,6 +119,25 @@ itself blueprint-data. The **identity id** (#171) is an additive sibling: --identity-id`. The byte-exact content id keeps the store/reproduce roles; the identity id is introspection-only until a dedup consumer exists. +**The override-reopened hash is ratified, not a defect (#343).** `exec`'s +`--override NODE.PARAM=VALUE` (above) reopens a bound param before bootstrap, so +the executed signal's canonical bytes — and therefore its `topology_hash` — are +the **reopened topology's own**, which may correspond to no blueprint ever +`put_blueprint`-stored, even for a "no-op" override that re-binds a param to its +existing bound value (the bound value still moves from `defaults` to `params`, +#249, changing the canonical bytes `topology_hash` covers). This is deliberate, +not accidental: an override **is** a topology-parameter variation, exactly as +the campaign leg's #246 axis-over-bound-param path has always minted the +reopened member's hash, never the referenced document's ([C18](c18-registry.md)'s +bound-override coincidence) — stamping the pre-override document's stored hash +on the single-run leg instead would make the two reopen mechanisms disagree +about what identity means for the same mechanism. Reproduction identity survives +regardless: the manifest's `params` map plus the base document deterministically +reconstruct the executed variant, byte-identically. An unresolved +`topology_hash` is the honest name of a real, reconstructible topology, not a +dangling reference — store-resolution (`get_blueprint`) is a lookup +convenience, never an identity guarantee. + ### Axis discovery `aura graph introspect --params ` (#328) is the one