feat(research,cli): the CostSpec campaign vocabulary (#234 task 2)

CampaignDoc gains an additive cost: Vec<CostSpec> block — a closed,
externally tagged vocabulary over the three shipped cost nodes
(constant/vol_slippage/carry, field names = the builders' ParamSpec
names), mirroring the risk block: serde default + skip-if-empty (cost-
less documents keep their content ids, pinned), DocFault::BadCost
validation (finite/bounds per component), std::cost construct-
vocabulary block + SlotKind::Costs + open-slot mirror, prose arm, and
all CampaignDoc struct literals across crates carry cost: vec![]
(compile-driven).

Verified: aura-research suite green, campaign validate e2e green, full
workspace suite green, clippy -D warnings clean; in-loop spec review
compliant and quality review approved (real diff fingerprint — the
worktree-anchored review prompt works).

refs #234
This commit is contained in:
2026-07-11 07:24:15 +02:00
parent 249aafdb0f
commit bdcf0452f4
6 changed files with 238 additions and 20 deletions
+53 -8
View File
@@ -597,7 +597,7 @@ fn campaign_introspect_vocabulary_lists_sections() {
// #216: std::risk joins the roster the CLI's own --vocabulary listing
// enumerates — the regime axis is discoverable from the public surface,
// not only from the design ledger or a generated document.
for id in ["std::data", "std::strategy", "std::process_ref", "std::presentation", "std::risk"] {
for id in ["std::data", "std::strategy", "std::process_ref", "std::presentation", "std::risk", "std::cost"] {
assert!(out.contains(id), "vocabulary misses {id}: {out}");
}
}
@@ -625,6 +625,23 @@ fn campaign_introspect_block_risk_describes_the_regime_shape() {
);
}
/// #234: `--block std::cost` names the component shape and its
/// absent-means-zero-costs semantics — the cost model is discoverable from
/// the public introspection surface (the `std::risk` #216 precedent).
#[test]
fn campaign_introspect_block_cost_describes_the_component_shape() {
let (out, code) = run_code(&["campaign", "introspect", "--block", "std::cost"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
assert!(
out.contains("optional, list of cost components { constant: { cost_per_trade } } | { vol_slippage: { slip_vol_mult } } | { carry: { carry_per_cycle } }; absent = zero costs (net == gross)"),
"the cost slot must render as optional with the component shape: {out}"
);
assert!(
out.contains("std::cost — campaign section: the cost model"),
"stdout/stderr: {out}"
);
}
/// #216: a complete-but-risk-less campaign document (the exact shape a
/// verb-generated document has before a `--stop-*` flag is added) has its one
/// remaining open slot — the optional risk axis — enumerated by the public
@@ -660,22 +677,23 @@ fn campaign_introspect_unwired_lists_the_optional_risk_slot_on_bare_envelope() {
);
}
/// #216: a bound (non-empty) risk list closes the slot — a complete document
/// with risk reports no open slots at all.
/// #216/#234: bound (non-empty) risk AND cost lists close both optional
/// slots — a complete document with both reports no open slots at all.
#[test]
fn campaign_introspect_unwired_omits_a_bound_risk_slot() {
fn campaign_introspect_unwired_omits_bound_risk_and_cost_slots() {
let dir = temp_cwd("campaign-introspect-unwired-risk-bound");
let with_risk = CAMPAIGN_DOC.replacen(
let with_both = CAMPAIGN_DOC.replacen(
"\"seed\": 1,",
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],",
"\"seed\": 1,\n \"risk\": [ { \"vol\": { \"length\": 3, \"k\": 2.0 } } ],\n \"cost\": [ { \"constant\": { \"cost_per_trade\": 2.0 } } ],",
1,
);
assert_ne!(with_risk, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
write_doc(&dir, "bound.campaign.json", &with_risk);
assert_ne!(with_both, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
write_doc(&dir, "bound.campaign.json", &with_both);
let (out, code) =
run_code_in(&dir, &["campaign", "introspect", "--unwired", "bound.campaign.json"]);
assert_eq!(code, Some(0));
assert!(!out.contains("open slot: risk"), "bound risk must not list: {out}");
assert!(!out.contains("open slot: cost"), "bound cost must not list: {out}");
assert!(out.contains("no open slots"), "stdout/stderr: {out}");
}
@@ -965,6 +983,33 @@ fn campaign_register_refuses_invalid_risk_section_and_writes_nothing() {
);
}
/// #234: the register validate-gate extends to the cost model — a document
/// whose only fault is a negative cost knob is refused with prose (exit 1)
/// and no file is ever written under `runs/campaigns/` (the risk-section
/// precedent).
#[test]
fn campaign_register_refuses_invalid_cost_section_and_writes_nothing() {
let dir = temp_cwd("campaign-register-invalid-cost");
let bad = CAMPAIGN_DOC.replacen(
"\"seed\": 1,",
"\"seed\": 1,\n \"cost\": [ { \"constant\": { \"cost_per_trade\": -2.0 } } ],",
1,
);
assert_ne!(bad, CAMPAIGN_DOC, "replacen must actually match the fixture's seed field");
write_doc(&dir, "bad-cost.campaign.json", &bad);
let (out, code) = run_code_in(&dir, &["campaign", "register", "bad-cost.campaign.json"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("refusing to register:"), "stdout/stderr: {out}");
assert!(
out.contains("cost[0]: the component's price-unit knob must be finite and >= 0"),
"stdout/stderr: {out}"
);
assert!(
!dir.join("runs").join("campaigns").exists(),
"register must not create a store entry for an invalid cost section"
);
}
/// Seed one open-param blueprint into the built demo project's store via a
/// real sweep and return its content id (the referential test's recipe).
fn seed_blueprint(dir: &Path, name: &str) -> String {